Umami Stats Widget widget screenshot

Script

Umami Stats Widget

by Brett Terpstra

Display daily visitor stats from your Umami analytics instance directly on a TerminalWidget sparkline chart.

Supports both self-hosted Umami (using your username/password login) and Umami Cloud (using an API key). You can configure the widget to display either total page views or unique visitors, complete with a rounded-bar sparkline and a direct tap action to open your analytics dashboard.

Requirements

  • TerminalWidget app installed and terminal-widget accessible on your PATH (typically /opt/homebrew/bin/terminal-widget or /usr/local/bin/terminal-widget)
  • curl and python3 (pre-installed on macOS)
  • An active Umami account (self-hosted or Umami Cloud)
  • A TerminalWidget widget added to your desktop or Notification Center with a Target name matching the TARGET variable in the script (default: umami-stats)

Configuration

Open the script in your favorite text editor and edit the # === CONFIGURATION === section at the top:

1. Widget and Site Settings

  • TARGET: The target name configured on your TerminalWidget (e.g. umami-stats or umami-blog).
  • SITE_NAME: The title to display at the top of the widget (e.g. My Blog).
  • WEBSITE_ID: Your website’s UUID found in your Umami dashboard settings or URL.
  • METRIC: Set to "views" for total pageviews or "visitors" for unique sessions.
  • DAYS: Number of days to include in the chart history (default: 7).
  • TIMEZONE: Timezone for daily bucketing (e.g. America/Chicago, America/New_York, UTC).
  • DASHBOARD_URL: Optional URL to open when clicking or tapping the widget (e.g. https://analytics.example.com/websites/YOUR_WEBSITE_ID).

2. Authentication

Choose one of the two authentication methods:

Option A — Self-Hosted Umami (Username & Password)

UMAMI_BASE="https://analytics.yourdomain.com"
UMAMI_USER="admin"
UMAMI_PASS="your-password"
UMAMI_API_KEY=""

The script logs in via the Umami API on each run to obtain a temporary Bearer token.

Option B — Umami Cloud or API Key

UMAMI_BASE="https://api.umami.is/v1"
UMAMI_API_KEY="your-umami-cloud-api-key"
UMAMI_USER=""
UMAMI_PASS=""

Generate an API key from Settings → API Keys in your Umami Cloud account.

3. Styling

  • FG: Foreground hex color for the title, text, and chart bars (default: ff69b4).
  • BG: Background hex color (default: 000000).
  • BAR_RADIUS: Corner rounding percentage for sparkline bars (default: 40).

Usage

  1. Save the script to your scripts directory (for example ~/bin/widget-umami-stats.sh).
  2. Make it executable:
chmod +x ~/bin/widget-umami-stats.sh
  1. Run the script manually to test your configuration:
~/bin/widget-umami-stats.sh

If successful, your target widget will refresh with a sparkline chart and a summary line like 7d · 1,420 views or 7d · 850 visitors.

Multiple Websites

To track multiple websites, make separate copies of the script (e.g., ~/bin/umami-blog.sh, ~/bin/umami-shop.sh). In each copy, set the appropriate TARGET, SITE_NAME, and WEBSITE_ID to target distinct widgets.

Running in the Background

You can automate updates on an interval (e.g., every 15 or 30 minutes) using launchd or cron. See the launchd instructions below for details on running in the background.

Script (umami.sh)

#!/usr/bin/env bash
# Refresh a TerminalWidget sparkline chart with 7-day Umami stats.
# Supports self-hosted Umami (user/pass) and Umami Cloud (API key).
#
# To track multiple websites, make a copy of this script for each site
# and configure distinct TARGET and WEBSITE_ID values.

set -euo pipefail

# ==============================================================================
# CONFIGURATION (Edit this block before running)
# ==============================================================================

# Widget target name in TerminalWidget (must match "Target name" in widget settings)
TARGET="umami-stats"

# Display title on the widget
SITE_NAME="My Website"

# Website ID from Umami (UUID found in website settings / URL)
WEBSITE_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# Metric to display: "views" (total pageviews) or "visitors" (unique sessions)
METRIC="views"

# Number of days to include in the sparkline chart
DAYS=7

# Timezone for date bucketing (e.g., "America/Chicago", "America/New_York", "UTC")
TIMEZONE="America/Chicago"

# Umami Base URL:
# - Self-hosted: "https://analytics.example.com"
# - Umami Cloud: "https://api.umami.is/v1" (or "https://cloud.umami.is")
UMAMI_BASE="https://analytics.example.com"

# --- AUTHENTICATION (Choose Option 1 OR Option 2) ---

# Option 1: Umami Cloud or API Key (Leave empty if using username/password)
UMAMI_API_KEY=""

# Option 2: Self-hosted Username & Password (Used only if UMAMI_API_KEY is empty)
UMAMI_USER=""
UMAMI_PASS=""

# --- OPTIONAL STYLING & ACTIONS ---

# Tap/click action: opens website dashboard in browser (leave empty to disable)
# Example: "https://analytics.example.com/websites/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
DASHBOARD_URL=""

# Widget colors and styling
FG="ff69b4"        # Chart & text color (hex)
BG="000000"        # Background color (hex)
BAR_RADIUS="40"    # Rounded sparkline bars (0 for sharp edges)

# Path to the terminal-widget binary
TW="/opt/homebrew/bin/terminal-widget"

# ==============================================================================
# END CONFIGURATION
# ==============================================================================

die() {
  echo "widget-umami-stats: $*" >&2
  exit 1
}

# Preflight checks
command -v "$TW" >/dev/null 2>&1 || die "terminal-widget CLI not found at $TW"
command -v curl >/dev/null 2>&1 || die "curl is required"
command -v python3 >/dev/null 2>&1 || die "python3 is required"

[[ -n "$TARGET" ]] || die "TARGET cannot be empty"
[[ -n "$WEBSITE_ID" && "$WEBSITE_ID" != "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" ]] \
  || die "Please set a valid WEBSITE_ID in the configuration block"

# Normalize metric selection
case "${METRIC,,}" in
  visitor|visitors|session|sessions|unique)
    METRIC_KEY="sessions"
    LABEL_SINGULAR="visitor"
    LABEL_PLURAL="visitors"
    ;;
  view|views|pageview|pageviews|*)
    METRIC_KEY="pageviews"
    LABEL_SINGULAR="view"
    LABEL_PLURAL="views"
    ;;
esac

# Determine authentication header
AUTH_HEADERS=()
if [[ -n "$UMAMI_API_KEY" ]]; then
  AUTH_HEADERS=(
    -H "x-umami-api-key: ${UMAMI_API_KEY}"
    -H "Authorization: Bearer ${UMAMI_API_KEY}"
  )
else
  if [[ -z "$UMAMI_USER" || -z "$UMAMI_PASS" ]]; then
    die "Provide either UMAMI_API_KEY (for Cloud) or both UMAMI_USER and UMAMI_PASS (for self-hosted)"
  fi

  login_url="${UMAMI_BASE%/}/api/auth/login"
  login_payload="$(
    U="$UMAMI_USER" P="$UMAMI_PASS" python3 -c '
import json, os
print(json.dumps({"username": os.environ["U"], "password": os.environ["P"]}))
'
  )"

  login_response="$(
    curl -fsS -X POST "$login_url" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d "$login_payload"
  )" || die "Authentication request failed at $login_url (check UMAMI_BASE and credentials)"

  TOKEN="$(python3 -c 'import json, sys; d=json.load(sys.stdin); print(d.get("token") or "")' <<<"$login_response")"
  [[ -n "$TOKEN" ]] || die "Login succeeded but returned no token"

  AUTH_HEADERS=(-H "Authorization: Bearer ${TOKEN}")
fi

# Time window calculation (milliseconds)
END_MS=$(( $(date +%s) * 1000 ))
START_MS=$(( END_MS - DAYS * 24 * 60 * 60 * 1000 ))

# Construct the pageviews API endpoint URL
base_clean="${UMAMI_BASE%/}"
if [[ "$base_clean" =~ /v[0-9]+$ || "$base_clean" =~ /api$ ]]; then
  api_url="${base_clean}/websites/${WEBSITE_ID}/pageviews"
else
  api_url="${base_clean}/api/websites/${WEBSITE_ID}/pageviews"
fi

query_url="${api_url}?startAt=${START_MS}&endAt=${END_MS}&unit=day&timezone=${TIMEZONE}"

# Fetch statistics
http_response="$(
  curl -sS -w "\n%{http_code}" "$query_url" \
    -H "Accept: application/json" \
    "${AUTH_HEADERS[@]}"
)" || die "Failed to connect to Umami API at $query_url"

http_code="${http_response##*$'\n'}"
json_body="${http_response%$'\n'*}"

if [[ "$http_code" != "200" ]]; then
  die "Umami API returned HTTP $http_code: ${json_body:0:200}"
fi

[[ -n "${json_body//[[:space:]]/}" ]] || die "Empty response body from Umami API"

# Parse JSON and compute continuous N-day series (fills missing days with 0)
parsed="$(
  UMAMI_STATS_JSON="$json_body" python3 - "$START_MS" "$END_MS" "$DAYS" "$METRIC_KEY" <<'PY'
import json, os, sys
from datetime import datetime, timezone, timedelta

start_ms, end_ms, days = map(int, sys.argv[1:4])
metric_key = sys.argv[4]

raw = os.environ.get("UMAMI_STATS_JSON", "")
if not raw.strip():
    raise SystemExit("Empty statistics payload")

payload = json.loads(raw)
rows = payload.get(metric_key) or []

def parse_date(x):
    if isinstance(x, (int, float)):
        return datetime.fromtimestamp(x / 1000 if x > 1e11 else x, tz=timezone.utc).date()
    s = str(x).strip().replace("Z", "+00:00")
    if len(s) == 10 and s.count("-") == 2:
        try:
            return datetime.strptime(s, "%Y-%m-%d").date()
        except Exception:
            pass
    try:
        return datetime.fromisoformat(s).date()
    except Exception:
        pass
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
        try:
            return datetime.strptime(s, fmt).date()
        except Exception:
            pass
    return None

by_day = {}
for row in rows:
    x = row.get("x")
    y = row.get("y") or 0
    if x is None:
        continue
    day = parse_date(x)
    if day:
        by_day[day] = by_day.get(day, 0) + int(y)

end_day = datetime.fromtimestamp(end_ms / 1000, tz=timezone.utc).date()
start_day = end_day - timedelta(days=days - 1)

values = []
total = 0
cur = start_day
while cur <= end_day:
    v = int(by_day.get(cur, 0))
    values.append(str(v))
    total += v
    cur += timedelta(days=1)

print("\t".join([" ".join(values), str(total)]))
PY
)" || die "Failed to parse Umami statistics payload"

chart_series="${parsed%%$'\t'*}"
total_count="${parsed#*$'\t'}"

label_unit="$LABEL_PLURAL"
if [[ "$total_count" -eq 1 ]]; then
  label_unit="$LABEL_SINGULAR"
fi

text_summary="${DAYS}d · ${total_count} ${label_unit}"

# Build terminal-widget command arguments
tw_cmd=(
  "$TW"
  --target "$TARGET"
  --title "$SITE_NAME"
  --title-color "$FG"
  --text "$text_summary"
  --chart "$chart_series"
  --chart-format sparkline
  --bar-radius "$BAR_RADIUS"
  --fg "$FG"
  --bg "$BG"
  --timestamp
  --base-zero
)

if [[ -n "$DASHBOARD_URL" ]]; then
  tw_cmd+=(--action-kind open-url --action-value "$DASHBOARD_URL")
fi

# Update the widget
"${tw_cmd[@]}"

echo "widget-umami-stats: updated target '${TARGET}' with ${total_count} ${label_unit} (${chart_series})"

Download script

Running in the background with launchd

Suggested interval: 1 hour (StartInterval = 3600 seconds).

Save the script from this recipe to ~/bin/umami-stats-widget.sh, then create a Launch Agent plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.terminalwidget.umami-stats-widget</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/umami-stats-widget.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>3600</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.umami-stats-widget.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.umami-stats-widget.err</string>
</dict>
</plist>

From Terminal:

mkdir -p ~/bin ~/Library/LaunchAgents
# Save your script to ~/bin/umami-stats-widget.sh
cat > ~/Library/LaunchAgents/com.terminalwidget.umami-stats-widget.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.terminalwidget.umami-stats-widget</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/umami-stats-widget.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>3600</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.umami-stats-widget.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.umami-stats-widget.err</string>
</dict>
</plist>
EOF
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.terminalwidget.umami-stats-widget.plist

For a GUI editor and troubleshooting, see LaunchControl from soma-zone.

← All recipes