diff --git a/README.md b/README.md index 072c9394..dcc1081e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,32 @@ Vitals is a GNOME Shell extension for displaying your computer's temperature, vo #####     Open the Extensions application and toggle on Vitals +## Custom Metrics + +Point Vitals at your own JSON file(s) to surface any metric you can write to disk — build status, a script's output, a remote API poll, anything. Enable it under Preferences → Monitor custom metrics → ⚙ → **Add JSON File…**. + +Each file is polled on the same interval as every other sensor (`update-time`) and must look like: + +```json +{ + "title": "Claude Code", + "metricsBarValue": "5.4%", + "metrics": [ + { "title": "Model", "formattedValue": "Sonnet 5" }, + { "title": "Context", "formattedValue": "5.4%" }, + { "title": "5h window", "formattedValue": "16.4%" } + ] +} +``` + +* `title` (required) — the source's name, used as the row/label prefix. +* `metricsBarValue` (optional) — shown on the source's pinnable summary row; falls back to the first metric's value if omitted. +* `metrics` (required) — array of `{ title, formattedValue }`. Each becomes its own row under the "Custom" menu, labeled ` · <metric title>`. + +Values are rendered exactly as provided — no unit conversion or rounding is applied, so format them the way you want them to appear. Any row (including the summary row) can be pinned to the top bar the same way any other sensor can. A missing or invalid file shows as disabled rather than breaking the extension. Give each source a unique `title` — two sources sharing a title will collide in the menu. + +**Example:** [custom_metrics/claude-code-usage/](custom_metrics/claude-code-usage/) is a ready-to-run script + login item that keeps a Claude Code usage source (model, context %, 5h/7d rate-limit usage) fed from Claude Code's own `statusLine` hook. + ## Credits Vitals was originally forked from [gnome-shell-extension-freon](https://github.com/UshakovVasilii/gnome-shell-extension-freon). I was having trouble finding an up to date, resource friendly and fully featured system monitoring tool. My biggest pet peeve was random system delays because of I/O blocking polls, and thus, the idea for Vitals was born! It has been refactored several times over, so most of the code is new or different. diff --git a/custom_metrics/claude-code-usage/README.md b/custom_metrics/claude-code-usage/README.md new file mode 100644 index 00000000..03b2ec04 --- /dev/null +++ b/custom_metrics/claude-code-usage/README.md @@ -0,0 +1,101 @@ +# Claude Code usage → Vitals Custom Metrics + +Feeds Claude Code's own `statusLine` data (model, context %, 5-hour and 7-day +rate-limit usage) into Vitals' [Custom Metrics](../../README.md#custom-metrics) +feature, so it shows up in your GNOME top bar / Vitals menu alongside your +other sensors. + +## How it works + +`claude-usage-statusline.sh` is a single script with two modes, chosen +automatically by how Claude Code invokes it: + +1. **statusLine mode.** Claude Code runs the script and pipes a JSON blob + describing the current session on stdin (after every assistant message, + `/compact`, permission-mode change, etc. - see [Claude Code's statusLine + docs](https://docs.claude.com/en/docs/claude-code/statusline)). The script + pulls out the model name, context-window usage, and 5h/7d rate-limit usage, + writes them to `~/.claude/claude-usage.json` in Vitals' custom-metrics JSON + format, and prints the normal status-line text back to stdout (this part is + required - it's what actually renders in Claude Code's status bar). + +2. **Bootstrap/install mode.** Run the script yourself (or let the login item + below run it) with no piped input, and it idempotently: + - adds a `statusLine` entry to `~/.claude/settings.json` pointing at itself + (merges in - it won't clobber other settings already in that file), + - installs `~/.config/autostart/claude-usage-vitals.desktop`, an XDG + autostart entry, so this bootstrap step re-runs every login, + - finds your installed Vitals extension's schema and adds + `~/.claude/claude-usage.json` to its `custom-metrics-paths` (falls back to + printing instructions if it can't find one), + - makes a best-effort attempt to refresh the file straight from the + `claude` CLI if it's on PATH (see "About the CLI refresh" below), + - writes a placeholder `~/.claude/claude-usage.json` if one still doesn't + exist after that, so Vitals has something to show before your first + Claude Code session of the day. + +Every step checks current state before acting, so running it again (e.g. at +every login) is a no-op once everything's already wired up. + +## Setup + +```sh +chmod +x claude-usage-statusline.sh +./claude-usage-statusline.sh +``` + +That's the only manual step. From then on: +- Every login re-verifies the wiring (via the autostart entry) and repairs + anything that got reset. +- Every Claude Code session updates `~/.claude/claude-usage.json` as you use it. + +To verify by hand instead of waiting for Claude Code: + +```sh +echo '{"model":{"display_name":"Sonnet 5"},"context_window":{"used_percentage":5.4}, +"rate_limits":{"five_hour":{"used_percentage":16.4},"seven_day":{"used_percentage":1.0}}}' \ + | ./claude-usage-statusline.sh +cat ~/.claude/claude-usage.json +``` + +## About the CLI refresh (`try_cli_usage_refresh`) + +The statusLine hook only updates while Claude Code is actively rendering a +session - so right after login, or on a machine that's never opened Claude +Code that day, the file would otherwise sit on stale or placeholder data. The +bootstrap step tries to close that gap by calling `claude -p "/usage" +--output-format json` directly, if `claude` is on `PATH`. + +**As of this writing, that call doesn't actually work** - `/usage` is +documented as a TUI-only command with no supported headless equivalent, so +this is speculative. The function is written defensively for that reason: it +only ever overwrites `claude-usage.json` if the output it gets back parses +into real percentages for the 5h/7d fields; anything else (an error, a +conversational non-answer, no `claude` binary at all) is silently ignored and +the existing file is left untouched. Today it's a no-op on every known Claude +Code version. If a future version adds a real headless usage command, update +the `claude -p ...` invocation and field names in `try_cli_usage_refresh` to +match its actual output - the validation/fallback logic around it doesn't need +to change. + +## Requirements + +- `jq` (`sudo apt install jq` / `sudo dnf install jq` / `sudo pacman -S jq`) +- `python3` (used only by the installer, to safely read-modify-write the + Vitals `custom-metrics-paths` array without touching anything else in it) +- `gsettings` (optional - only needed for automatic registration with Vitals; + the script degrades gracefully to printing manual instructions without it) + +## Caveats + +- `rate_limits` in the statusLine payload is only populated for Claude.ai + subscribers (Pro/Max), and only after the first API response of a session. + Until then (or on API/Console accounts), the 5h/7d rows show `N/A`. +- The exact statusLine JSON schema is Claude Code's, not Vitals' or this + script's - if a future Claude Code version renames or removes a field, the + affected metric just falls back to `N/A` rather than breaking the script + (everything is read via `// empty` jq fallbacks). +- If you ever want to stop this, remove the `statusLine` key from + `~/.claude/settings.json`, delete + `~/.config/autostart/claude-usage-vitals.desktop`, and remove the path + from Vitals' custom-metrics-paths in Preferences. diff --git a/custom_metrics/claude-code-usage/claude-usage-statusline.sh b/custom_metrics/claude-code-usage/claude-usage-statusline.sh new file mode 100755 index 00000000..a97f4dd4 --- /dev/null +++ b/custom_metrics/claude-code-usage/claude-usage-statusline.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# +# Vitals Custom Metrics companion script for Claude Code. +# +# Dual purpose, detected automatically by how it's invoked: +# +# 1. Claude Code invokes it as a `statusLine` command, piping session JSON +# on stdin. In that mode it writes/refreshes the Vitals custom-metrics +# JSON file (model, context %, 5h/7d rate-limit usage) and prints the +# normal status line text Claude Code expects back on stdout. +# +# 2. Run directly by a human (or at login, with no stdin piped) it acts as +# its own installer: it wires itself into Claude Code's settings.json as +# the statusLine command, drops an XDG autostart entry so it re-runs +# this bootstrap at every login, and points Vitals' custom-metrics-paths +# setting at the JSON file it produces. All steps are idempotent - safe +# to re-run any time. It also makes a best-effort attempt to refresh +# from `claude`'s own /usage data (see try_cli_usage_refresh below) so +# there's something recent to show even between active sessions. +# +# See README.md in this directory for setup instructions and the JSON schema. + +set -euo pipefail + +CLAUDE_USAGE_FILE="${CLAUDE_USAGE_FILE:-$HOME/.claude/claude-usage.json}" +CLAUDE_SETTINGS="${CLAUDE_SETTINGS:-$HOME/.claude/settings.json}" +AUTOSTART_DESKTOP="$HOME/.config/autostart/claude-usage-vitals.desktop" +VITALS_SCHEMA="org.gnome.shell.extensions.vitals" + +script_path() { + # resolve our own path even if invoked via a relative path or symlink + local dir base + dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + base="$(basename "${BASH_SOURCE[0]}")" + printf '%s/%s' "$dir" "$base" +} + +require_jq() { + if ! command -v jq >/dev/null 2>&1; then + echo "jq is required but not installed. Install it with one of:" >&2 + echo " sudo apt install jq # Debian/Ubuntu" >&2 + echo " sudo dnf install jq # Fedora" >&2 + echo " sudo pacman -S jq # Arch/Manjaro" >&2 + exit 1 + fi +} + +# writes the Vitals custom-metrics JSON file atomically (temp file + mv, same +# directory, so the mv is a same-filesystem rename - Vitals never reads a +# half-written file) +write_metrics_json() { + local model="$1" context_disp="$2" five_disp="$3" seven_disp="$4" + mkdir -p "$(dirname "$CLAUDE_USAGE_FILE")" + local tmp + tmp="$(mktemp "${CLAUDE_USAGE_FILE}.XXXXXX")" + jq -n \ + --arg model "$model" \ + --arg context "$context_disp" \ + --arg five "$five_disp" \ + --arg seven "$seven_disp" \ + '{ + title: "Claude Code", + metricsBarValue: $context, + metrics: [ + { title: "Model", formattedValue: $model }, + { title: "Context", formattedValue: $context }, + { title: "5h window", formattedValue: $five }, + { title: "7d window", formattedValue: $seven } + ] + }' > "$tmp" + mv "$tmp" "$CLAUDE_USAGE_FILE" +} + +# --- statusLine mode: Claude Code piped us session JSON on stdin --- +handle_statusline() { + require_jq + local input + input="$(cat)" + + # debug: always keep the last raw payload around so field-mapping issues + # (e.g. rate_limits not showing up) can be diagnosed against what Claude + # Code is actually sending, rather than what the docs say it sends + printf '%s' "$input" > "$HOME/.claude/claude-usage-debug.json" 2>/dev/null || true + + local model context five_hour seven_day + model="$(jq -r '.model.display_name // .model.id // "Claude Code"' <<<"$input")" + context="$(jq -r '.context_window.used_percentage // empty' <<<"$input")" + five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$input")" + seven_day="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$input")" + + local context_disp="N/A" five_disp="N/A" seven_disp="N/A" + [ -n "$context" ] && context_disp="${context}%" + [ -n "$five_hour" ] && five_disp="${five_hour}%" + [ -n "$seven_day" ] && seven_disp="${seven_day}%" + + write_metrics_json "$model" "$context_disp" "$five_disp" "$seven_disp" + + # this line is what actually renders in Claude Code's status bar + printf '%s | ctx %s | 5h %s | 7d %s\n' "$model" "$context_disp" "$five_disp" "$seven_disp" +} + +# --- best-effort refresh via the `claude` CLI directly, for when no active +# session's statusLine has run recently (e.g. right after login) --- +# +# As of this writing, Claude Code's `/usage` is a TUI-only command - there is +# no documented, scriptable way to fetch current 5h/7d rate-limit percentages +# outside of an active interactive session (the statusLine hook above is the +# only supported mechanism). This function is deliberately conservative: it +# only ever overwrites the metrics file if it gets back output it can +# actually parse into real percentages, so today it silently no-ops on every +# known Claude Code version rather than write anything approximate or +# fabricated. It exists so that if/when Claude Code adds a headless usage +# command, this starts working without any changes needed here - update the +# parsing below to match whatever that command actually outputs. +try_cli_usage_refresh() { + command -v claude >/dev/null 2>&1 || return 0 + + local output + output="$(timeout 10 claude -p "/usage" --output-format json 2>/dev/null)" || return 0 + [ -n "$output" ] || return 0 + + require_jq + # speculative field names, matching the statusLine schema this script + # already understands - adjust if a real headless usage command ships + # with a different shape + local model context five_hour seven_day + model="$(jq -r '.model.display_name // .model.id // empty' <<<"$output" 2>/dev/null)" || return 0 + context="$(jq -r '.context_window.used_percentage // empty' <<<"$output" 2>/dev/null)" + five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$output" 2>/dev/null)" + seven_day="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$output" 2>/dev/null)" + + # only trust this enough to write the file if at least the rate-limit + # fields we actually care about came back as real numbers + [[ "$five_hour" =~ ^[0-9]+(\.[0-9]+)?$ ]] || return 0 + [[ "$seven_day" =~ ^[0-9]+(\.[0-9]+)?$ ]] || return 0 + + local context_disp="N/A" + [ -n "$context" ] && context_disp="${context}%" + write_metrics_json "${model:-Claude Code}" "$context_disp" "${five_hour}%" "${seven_day}%" + echo "Refreshed $CLAUDE_USAGE_FILE from claude CLI" +} + +# --- bootstrap mode: wire everything up, idempotently --- +install_statusline_hook() { + require_jq + local self + self="$(script_path)" + mkdir -p "$(dirname "$CLAUDE_SETTINGS")" + [ -f "$CLAUDE_SETTINGS" ] || echo '{}' > "$CLAUDE_SETTINGS" + + if jq -e --arg cmd "$self" '.statusLine.command == $cmd' "$CLAUDE_SETTINGS" >/dev/null 2>&1; then + echo "statusLine already points at $self" + return + fi + + local tmp + tmp="$(mktemp)" + jq --arg cmd "$self" '.statusLine = {type: "command", command: $cmd}' "$CLAUDE_SETTINGS" > "$tmp" + mv "$tmp" "$CLAUDE_SETTINGS" + echo "Configured statusLine command in $CLAUDE_SETTINGS" +} + +install_autostart() { + local self + self="$(script_path)" + mkdir -p "$(dirname "$AUTOSTART_DESKTOP")" + if [ -f "$AUTOSTART_DESKTOP" ]; then + echo "Login item already installed: $AUTOSTART_DESKTOP" + return + fi + cat > "$AUTOSTART_DESKTOP" <<EOF +[Desktop Entry] +Type=Application +Name=Claude Code Usage for Vitals +Comment=Keeps ~/.claude/claude-usage.json wired up for Vitals' custom metrics +Exec=$self --bootstrap +X-GNOME-Autostart-enabled=true +NoDisplay=true +Terminal=false +EOF + echo "Installed login item: $AUTOSTART_DESKTOP" +} + +# Vitals' schema is never registered system-wide - gnome-shell loads it +# straight out of the extension's own schemas/ directory, so plain +# `gsettings get/set org.gnome.shell.extensions.vitals ...` finds nothing. +# We have to point --schemadir at a real installed copy's schemas/ dir. +# (All Vitals-family extensions share the same fixed dconf path, so it +# doesn't matter which installed copy we borrow the compiled schema from - +# writing through any one of them updates the value every copy reads.) +find_vitals_schema_dir() { + local ext_dir + for ext_dir in "$HOME/.local/share/gnome-shell/extensions/"*/ /usr/share/gnome-shell/extensions/*/; do + [ -f "${ext_dir}metadata.json" ] || continue + if grep -q '"settings-schema"[[:space:]]*:[[:space:]]*"org.gnome.shell.extensions.vitals"' "${ext_dir}metadata.json" 2>/dev/null \ + && [ -f "${ext_dir}schemas/gschemas.compiled" ]; then + printf '%s' "${ext_dir}schemas" + return 0 + fi + done + return 1 +} + +register_with_vitals() { + if ! command -v gsettings >/dev/null 2>&1; then + echo "gsettings not found - add $CLAUDE_USAGE_FILE to Vitals' Custom Metrics preferences manually." + return + fi + + local schema_dir + if ! schema_dir="$(find_vitals_schema_dir)"; then + echo "Couldn't find an installed Vitals extension - add $CLAUDE_USAGE_FILE to Vitals' Custom Metrics preferences manually." + return + fi + + # Read-modify-write the array-of-strings key. Done in Python rather than + # bash string surgery: gsettings' array literal formatting (spacing, + # occasional line-wrapping for long values) isn't worth hand-parsing, and + # a botched write here corrupts a real user setting, not a scratch file. + if ! GSETTINGS_SCHEMADIR="$schema_dir" VITALS_SCHEMA="$VITALS_SCHEMA" CLAUDE_USAGE_FILE="$CLAUDE_USAGE_FILE" python3 <<'PYEOF' +import ast +import os +import subprocess +import sys +import time + +schema_dir = os.environ["GSETTINGS_SCHEMADIR"] +schema = os.environ["VITALS_SCHEMA"] +path = os.environ["CLAUDE_USAGE_FILE"] +key = "custom-metrics-paths" + +def read_paths(): + result = subprocess.run( + ["gsettings", "--schemadir", schema_dir, "get", schema, key], + capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"Couldn't read {schema} {key}: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + + value_text = result.stdout.strip() + # gsettings sometimes prints the GVariant type annotation ahead of the + # literal (e.g. "@as []" for an empty array) instead of a bare + # Python-compatible literal - strip any such "@xyz " prefix before parsing. + if value_text.startswith("@"): + value_text = value_text.split(" ", 1)[1] if " " in value_text else "[]" + return value_text + +value_text = read_paths() +if value_text == "[]": + # repeated re-reads guard against this coming back empty due to a + # transient schema-cache staleness right after (re)compiling schemas - + # never worth risking silently clobbering a real, non-empty setting with + # a lone entry over one unlucky read + for delay in (0.3, 0.6, 1.0): + time.sleep(delay) + value_text = read_paths() + if value_text != "[]": + break + +try: + paths = ast.literal_eval(value_text) +except (ValueError, SyntaxError) as e: + print(f"Couldn't parse existing {key} value ({result.stdout.strip()!r}): {e}", file=sys.stderr) + sys.exit(1) + +if path in paths: + print(f"Vitals is already watching {path}") + sys.exit(0) + +paths.append(path) +literal = "[" + ", ".join("'" + p.replace("\\", "\\\\").replace("'", "\\'") + "'" for p in paths) + "]" +result = subprocess.run(["gsettings", "--schemadir", schema_dir, "set", schema, key, literal]) +if result.returncode != 0: + sys.exit(1) +print(f"Registered {path} with Vitals' custom-metrics-paths (via {schema_dir})") +PYEOF + then + echo "Couldn't register with Vitals automatically - add $CLAUDE_USAGE_FILE to its Custom Metrics preferences manually." >&2 + fi +} + +bootstrap() { + install_statusline_hook + install_autostart + register_with_vitals + try_cli_usage_refresh + if [ ! -f "$CLAUDE_USAGE_FILE" ]; then + write_metrics_json "Claude Code" "idle" "idle" "idle" + echo "Wrote placeholder $CLAUDE_USAGE_FILE - it'll fill in real values the next time Claude Code renders its status line." + fi + echo "Done." +} + +# --- entry point --- +# Explicit args always win, regardless of stdin - the autostart .desktop entry +# invokes us with --bootstrap and /dev/null on stdin, which looks identical to +# "non-tty" from a plain `[ -t 0 ]` check, so that alone can't distinguish the +# two modes. Only fall back to stdin-sniffing when invoked with no arguments +# at all, which is how Claude Code itself calls the statusLine command. +case "${1:-}" in + --bootstrap) bootstrap ;; + --help|-h) + echo "Usage: $0 [--bootstrap]" + echo " --bootstrap: install/repair the statusLine hook, login item, and Vitals registration." + echo " No args, stdin piped (used internally by Claude Code): parse session JSON and refresh $CLAUDE_USAGE_FILE." + echo " No args, stdin a tty (i.e. run by hand): same as --bootstrap." + ;; + "") + if [ -t 0 ]; then + bootstrap + else + handle_statusline + fi + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; +esac diff --git a/extension.js b/extension.js index de33e4e7..9df83b2d 100644 --- a/extension.js +++ b/extension.js @@ -291,7 +291,8 @@ var VitalsMenuButton = GObject.registerClass({ }, 'storage' : { 'icon': 'storage-symbolic.svg' }, 'battery' : { 'icon': 'battery-symbolic.svg' }, - 'gpu' : { 'icon': 'gpu-symbolic.svg' } + 'gpu' : { 'icon': 'gpu-symbolic.svg' }, + 'custom' : { 'icon': 'custom-symbolic.svg' } } // list with the prefixes for the according themes, the index of each @@ -349,7 +350,7 @@ var VitalsMenuButton = GObject.registerClass({ 'fixed-widths', 'hide-icons', 'unit', 'memory-measurement', 'include-public-ip', 'network-public-ip-interval', 'network-public-ip-show-flag', 'network-public-ip-provider', 'network-speed-format', 'network-speed-unit', 'storage-measurement', - 'include-static-info', 'include-static-gpu-info' ]; + 'include-static-info', 'include-static-gpu-info', 'custom-metrics-paths' ]; for (let setting of settings) this._settings.connectObject('changed::' + setting, this._redrawMenu.bind(this), this); diff --git a/icons/gnome/custom-symbolic.svg b/icons/gnome/custom-symbolic.svg new file mode 100644 index 00000000..c64d6b6f --- /dev/null +++ b/icons/gnome/custom-symbolic.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path fill="#222" d="M1 1a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h14a1 1 0 0 0 0-2H2V2a1 1 0 0 0-1-1zm12 1a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0V3a1 1 0 0 0-1-1zM9 5a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V6a1 1 0 0 0-1-1zM5 8a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1z"/></svg> diff --git a/icons/original/custom-symbolic.svg b/icons/original/custom-symbolic.svg new file mode 100644 index 00000000..63ea9c1f --- /dev/null +++ b/icons/original/custom-symbolic.svg @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" version="1.1"> + <path style="fill:#bebebe" d="M1 1a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h14a1 1 0 0 0 0-2H2V2a1 1 0 0 0-1-1zm12 1a1 1 0 0 0-1 1v7a1 1 0 0 0 2 0V3a1 1 0 0 0-1-1zM9 5a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V6a1 1 0 0 0-1-1zM5 8a1 1 0 0 0-1 1v2a1 1 0 0 0 2 0V9a1 1 0 0 0-1-1z"/> +</svg> diff --git a/prefs.js b/prefs.js index 332da0c1..4e4261df 100644 --- a/prefs.js +++ b/prefs.js @@ -49,7 +49,7 @@ const Settings = new GObject.Class({ 'alphabetize', 'hide-zeros', 'include-public-ip', 'network-public-ip-show-flag', 'show-battery', 'fixed-widths', 'hide-icons', 'menu-centered', 'include-static-info', - 'show-gpu', 'include-static-gpu-info' ]; + 'show-gpu', 'include-static-gpu-info', 'show-custom' ]; for (let key in sensors) { let sensor = sensors[key]; @@ -102,7 +102,7 @@ const Settings = new GObject.Class({ } // makes individual sensor preference boxes appear - sensors = [ 'temperature', 'network', 'storage', 'memory', 'battery', 'system', 'processor', 'gpu' ]; + sensors = [ 'temperature', 'network', 'storage', 'memory', 'battery', 'system', 'processor', 'gpu', 'custom' ]; for (let key in sensors) { let sensor = sensors[key]; @@ -127,6 +127,72 @@ const Settings = new GObject.Class({ dialog.show(); }); } + + this._bindCustomMetrics(); + }, + + // manage the list of custom metrics JSON files + _bindCustomMetrics: function() { + this._customPathsListbox = this.builder.get_object('custom-paths-listbox'); + + this._refreshCustomPathsListbox(); + + this.builder.get_object('custom-add-path').connect('clicked', () => { + let dialog = new Gtk.FileDialog({ title: _('Select Custom Metrics JSON File') }); + + let filter = new Gtk.FileFilter(); + filter.add_pattern('*.json'); + filter.set_name(_('JSON files')); + let filters = new Gio.ListStore({ item_type: Gtk.FileFilter }); + filters.append(filter); + dialog.set_filters(filters); + + dialog.open(this.widget.get_root(), null, (self, res) => { + try { + let file = self.open_finish(res); + let path = file.get_path(); + if (!path) return; + + let paths = this._settings.get_strv('custom-metrics-paths'); + if (!paths.includes(path)) { + paths.push(path); + this._settings.set_strv('custom-metrics-paths', paths); + this._refreshCustomPathsListbox(); + } + } catch (e) { } // user cancelled the dialog + }); + }); + }, + + _refreshCustomPathsListbox: function() { + let listbox = this._customPathsListbox; + + // clear existing rows + let row; + while ((row = listbox.get_row_at_index(0)) !== null) + listbox.remove(row); + + let paths = this._settings.get_strv('custom-metrics-paths'); + for (let path of paths) { + let box = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 12, + margin_top: 6, margin_bottom: 6, margin_start: 6, margin_end: 6, + }); + + let label = new Gtk.Label({ label: path, hexpand: true, halign: Gtk.Align.START, ellipsize: 3 }); + box.append(label); + + let removeButton = new Gtk.Button({ icon_name: 'user-trash-symbolic' }); + removeButton.connect('clicked', () => { + let updated = this._settings.get_strv('custom-metrics-paths').filter(p => p !== path); + this._settings.set_strv('custom-metrics-paths', updated); + this._refreshCustomPathsListbox(); + }); + box.append(removeButton); + + listbox.append(new Gtk.ListBoxRow({ child: box, selectable: false })); + } } }); diff --git a/prefs.ui b/prefs.ui index 101b20bc..fa5c3d4f 100644 --- a/prefs.ui +++ b/prefs.ui @@ -1064,6 +1064,59 @@ </property> </object> </child> + <child> + <object class="GtkListBoxRow"> + <property name="width_request">100</property> + <property name="selectable">0</property> + <property name="child"> + <object class="GtkBox"> + <property name="can_focus">0</property> + <property name="valign">center</property> + <property name="margin_top">6</property> + <property name="margin_bottom">6</property> + <child> + <object class="GtkLabel"> + <property name="hexpand">1</property> + <property name="can_focus">0</property> + <property name="halign">start</property> + <property name="margin-start">5</property> + <property name="margin-end">5</property> + <property name="label" translatable="yes">Monitor custom metrics</property> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="can_focus">0</property> + <property name="spacing">6</property> + <child> + <object class="GtkButton" id="custom-prefs"> + <property name="receives_default">1</property> + <property name="halign">center</property> + <property name="valign">center</property> + <child> + <object class="GtkImage"> + <property name="can_focus">0</property> + <property name="icon_name">emblem-system-symbolic</property> + </object> + </child> + <style> + <class name="circular"/> + </style> + </object> + </child> + <child> + <object class="GtkSwitch" id="show-custom"> + <property name="halign">end</property> + <property name="valign">center</property> + <property name="margin-end">5</property> + </object> + </child> + </object> + </child> + </object> + </property> + </object> + </child> </object> </child> <child type="label_item"> @@ -1521,4 +1574,44 @@ </object> </child> </object> + <object class="GtkBox" id="custom_prefs"> + <property name="can_focus">1</property> + <property name="margin_top">12</property> + <property name="margin_bottom">12</property> + <property name="margin_start">6</property> + <property name="margin_end">6</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <child> + <object class="GtkLabel"> + <property name="can_focus">0</property> + <property name="halign">start</property> + <property name="wrap">1</property> + <property name="label" translatable="yes">Point Vitals at JSON files that expose your own metrics (title, metricsBarValue, metrics[]). Each source appears in the Custom Metrics menu and any row can be pinned to the top bar.</property> + </object> + </child> + <child> + <object class="GtkFrame"> + <property name="can_focus">1</property> + <property name="child"> + <object class="GtkListBox" id="custom-paths-listbox"> + <property name="can_focus">1</property> + <property name="selection_mode">none</property> + </object> + </property> + </object> + </child> + <child> + <object class="GtkBox"> + <property name="can_focus">0</property> + <property name="halign">end</property> + <child> + <object class="GtkButton" id="custom-add-path"> + <property name="receives_default">1</property> + <property name="label" translatable="yes">Add JSON File…</property> + </object> + </child> + </object> + </child> + </object> </interface> diff --git a/schemas/org.gnome.shell.extensions.vitals.gschema.xml b/schemas/org.gnome.shell.extensions.vitals.gschema.xml index 4f0b1860..20432ba0 100644 --- a/schemas/org.gnome.shell.extensions.vitals.gschema.xml +++ b/schemas/org.gnome.shell.extensions.vitals.gschema.xml @@ -169,5 +169,15 @@ <summary>Icon styles</summary> <description>Set the style for the displayed sensor icons ('original', 'updated')</description> </key> + <key type="b" name="show-custom"> + <default>true</default> + <summary>Monitor custom metrics</summary> + <description>Display metrics loaded from user-provided JSON files</description> + </key> + <key name="custom-metrics-paths" type="as"> + <default>[]</default> + <summary>Custom metrics JSON file paths</summary> + <description>List of absolute paths to JSON files describing custom metrics</description> + </key> </schema> </schemalist> diff --git a/sensors.js b/sensors.js index 9355af1e..48b2e5c7 100644 --- a/sensors.js +++ b/sensors.js @@ -543,6 +543,47 @@ export const Sensors = GObject.registerClass({ }).catch(err => { }); } + _queryCustom(callback) { + let paths = this._settings.get_strv('custom-metrics-paths'); + + for (let path of paths) { + new FileModule.File(path).read().then(contents => { + let data; + try { + data = JSON.parse(contents); + } catch (e) { + this._returnValue(callback, this._customSourceTitle(path), 'disabled', 'custom', 'string'); + return; + } + + if (!data || typeof data.title !== 'string' || !Array.isArray(data.metrics)) { + this._returnValue(callback, this._customSourceTitle(path), 'disabled', 'custom', 'string'); + return; + } + + let title = data.title; + let barValue = (typeof data.metricsBarValue === 'string' && data.metricsBarValue) + ? data.metricsBarValue + : ((data.metrics[0] && typeof data.metrics[0].formattedValue === 'string') ? data.metrics[0].formattedValue : 'N/A'); + + // pinnable summary row, shown in the group and available for the top bar + this._returnValue(callback, title, barValue, 'custom', 'string'); + this._returnValue(callback, 'custom', barValue, 'custom-group', 'string'); + + for (let metric of data.metrics) { + if (!metric || typeof metric.title !== 'string' || typeof metric.formattedValue !== 'string') continue; + this._returnValue(callback, title + ' · ' + metric.title, metric.formattedValue, 'custom', 'string'); + } + }).catch(err => { + this._returnValue(callback, this._customSourceTitle(path), 'disabled', 'custom', 'string'); + }); + } + } + + _customSourceTitle(path) { + return path.split('/').pop().replace(/\.json$/i, ''); + } + _initFrameMonitor() { if (this._frameMonitorSignalId) return; this._frameMonitorLastTime = 0;