burnmeter 0.3.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- burnmeter/__init__.py +6 -0
- burnmeter/__main__.py +3 -0
- burnmeter/_proc.py +13 -0
- burnmeter/_worker.py +141 -0
- burnmeter/alerts.py +223 -0
- burnmeter/analytics.py +2029 -0
- burnmeter/assets/burnmeter.ico +0 -0
- burnmeter/assets/burnmeter.png +0 -0
- burnmeter/chat_titles.py +107 -0
- burnmeter/cli.py +1053 -0
- burnmeter/codex_parser.py +416 -0
- burnmeter/desktop.py +202 -0
- burnmeter/firebase_config.py +20 -0
- burnmeter/firebase_sync.py +472 -0
- burnmeter/parser.py +837 -0
- burnmeter/pricing.py +529 -0
- burnmeter/server.py +1431 -0
- burnmeter/static/chart.umd.js +14 -0
- burnmeter/static/chart.umd.js.LICENSE.txt +25 -0
- burnmeter/static/dashboard.css +843 -0
- burnmeter/static/dashboard.html +385 -0
- burnmeter/static/dashboard.js +2875 -0
- burnmeter/static/pricing.html +121 -0
- burnmeter/statusline.py +95 -0
- burnmeter/sync.py +444 -0
- burnmeter/sync_relay.py +825 -0
- burnmeter/tray.py +258 -0
- burnmeter/updater.py +108 -0
- burnmeter/window.py +569 -0
- burnmeter-0.3.8.dist-info/METADATA +296 -0
- burnmeter-0.3.8.dist-info/RECORD +36 -0
- burnmeter-0.3.8.dist-info/WHEEL +5 -0
- burnmeter-0.3.8.dist-info/entry_points.txt +2 -0
- burnmeter-0.3.8.dist-info/licenses/LICENSE +661 -0
- burnmeter-0.3.8.dist-info/licenses/LICENSING.md +49 -0
- burnmeter-0.3.8.dist-info/top_level.txt +1 -0
burnmeter/__init__.py
ADDED
burnmeter/__main__.py
ADDED
burnmeter/_proc.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Process helper.
|
|
2
|
+
|
|
3
|
+
When Burnmeter runs windowless (system tray / pythonw — no console of its own),
|
|
4
|
+
any console child process it spawns (git, the build worker, taskkill, powershell)
|
|
5
|
+
would otherwise POP its own console window for the instant it runs. Across a
|
|
6
|
+
running session that shows up as cmd windows "flashing" every cache rebuild.
|
|
7
|
+
Passing CREATE_NO_WINDOW suppresses that window. On non-Windows it's 0 (no-op),
|
|
8
|
+
so call sites can pass `creationflags=NO_WINDOW` unconditionally.
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
# CREATE_NO_WINDOW (0x08000000) on Windows; 0 elsewhere.
|
|
13
|
+
NO_WINDOW = 0x08000000 if sys.platform.startswith("win") else 0
|
burnmeter/_worker.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Short-lived build worker — bounds the long-running server's RSS.
|
|
2
|
+
|
|
3
|
+
The server spawns this as a subprocess, sends a JSON config on stdin, and reads
|
|
4
|
+
the built report JSON from stdout. The worker loads ~half-a-million records,
|
|
5
|
+
builds the report (transiently spiking to ~800MB), prints the JSON, then EXITS
|
|
6
|
+
— so the OS reclaims that memory immediately. The long-running server process
|
|
7
|
+
only ever holds the small report JSON (~0.5MB), never the raw records.
|
|
8
|
+
|
|
9
|
+
This is a zero-logic-change RAM fix: the exact same load_records / build_report
|
|
10
|
+
run, just in a process that dies after each build. No streaming-aggregate
|
|
11
|
+
rewrite, no risk of subtly wrong numbers.
|
|
12
|
+
|
|
13
|
+
Usage: `python3 -m burnmeter._worker` then write config JSON to stdin:
|
|
14
|
+
{"source":"codex","projects_dir":"...","extra_roots":[...],
|
|
15
|
+
"codex_dir":"...","codex_extra_roots":[...],"plan":null}
|
|
16
|
+
Prints report JSON to stdout. Non-zero exit on error.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _exit_with_parent(poll: float = 2.0) -> None:
|
|
29
|
+
"""Exit when the server that spawned this worker dies.
|
|
30
|
+
|
|
31
|
+
A killed or crashed server left its worker running until the build ended:
|
|
32
|
+
1.2-1.8 GB for minutes, and a restarted server started a second one. The
|
|
33
|
+
server passes its pid; the worker removes its config and report files, which
|
|
34
|
+
no one will read or delete now, and exits. Runs as a daemon thread.
|
|
35
|
+
"""
|
|
36
|
+
ppid = int(os.environ.get("BURNMETER_WORKER_PARENT") or os.getppid())
|
|
37
|
+
if sys.platform.startswith("win"):
|
|
38
|
+
import ctypes
|
|
39
|
+
k32 = ctypes.WinDLL("kernel32")
|
|
40
|
+
k32.OpenProcess.restype = ctypes.c_void_p
|
|
41
|
+
k32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
|
|
42
|
+
k32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
|
43
|
+
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE; None: already gone
|
|
44
|
+
if h:
|
|
45
|
+
k32.WaitForSingleObject(h, 0xFFFFFFFF) # INFINITE
|
|
46
|
+
else:
|
|
47
|
+
while os.getppid() == ppid: # re-parented once it dies
|
|
48
|
+
time.sleep(poll)
|
|
49
|
+
for var in ("BURNMETER_WORKER_IN", "BURNMETER_WORKER_OUT", "BURNMETER_WORKER_PROGRESS"):
|
|
50
|
+
try:
|
|
51
|
+
os.unlink(os.environ[var])
|
|
52
|
+
except (KeyError, OSError):
|
|
53
|
+
pass
|
|
54
|
+
os._exit(3)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _progress_writer(path):
|
|
58
|
+
"""A callback that keeps the read's progress in `path` for the server's loading
|
|
59
|
+
screen: the latest counts, merged and written whole (temp file, then rename), so
|
|
60
|
+
the server never reads half a file. None when no path was given."""
|
|
61
|
+
if not path:
|
|
62
|
+
return None
|
|
63
|
+
state = {"phase": "reading", "started_at": time.time()}
|
|
64
|
+
|
|
65
|
+
def write(update: dict) -> None:
|
|
66
|
+
state.update(update)
|
|
67
|
+
tmp = f"{path}.tmp"
|
|
68
|
+
try:
|
|
69
|
+
Path(tmp).write_text(json.dumps(state), encoding="utf-8")
|
|
70
|
+
os.replace(tmp, path)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
write({})
|
|
74
|
+
return write
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> int:
|
|
78
|
+
threading.Thread(target=_exit_with_parent, name="parent-watch", daemon=True).start()
|
|
79
|
+
# Config in / report out go through FILES (env vars) when set — a frozen
|
|
80
|
+
# --windowed (no-console) process has no usable stdin/stdout, so writing the
|
|
81
|
+
# report to stdout raised OSError [Errno 22]. Fall back to stdin/stdout for
|
|
82
|
+
# plain `python -m burnmeter._worker` use.
|
|
83
|
+
src = os.environ.get("BURNMETER_WORKER_IN")
|
|
84
|
+
raw = Path(src).read_text(encoding="utf-8") if src else (sys.stdin.read() or "{}")
|
|
85
|
+
try:
|
|
86
|
+
cfg = json.loads(raw or "{}")
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
try:
|
|
89
|
+
sys.stderr.write(f"[worker] bad config: {e}\n")
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
return 2
|
|
93
|
+
|
|
94
|
+
source = cfg.get("source", "claude")
|
|
95
|
+
plan = cfg.get("plan")
|
|
96
|
+
progress = _progress_writer(os.environ.get("BURNMETER_WORKER_PROGRESS"))
|
|
97
|
+
|
|
98
|
+
from .analytics import build_report
|
|
99
|
+
|
|
100
|
+
if source == "codex":
|
|
101
|
+
from .codex_parser import load_codex_records, CODEX_SESSIONS_DIR
|
|
102
|
+
root = Path(cfg["codex_dir"]) if cfg.get("codex_dir") else CODEX_SESSIONS_DIR
|
|
103
|
+
extra = [Path(p) for p in (cfg.get("codex_extra_roots") or [])]
|
|
104
|
+
records, stats, intents = load_codex_records(
|
|
105
|
+
root, extra_roots=extra, since_days=int(cfg.get("codex_since_days", 90)),
|
|
106
|
+
progress=progress)
|
|
107
|
+
errors = []
|
|
108
|
+
else:
|
|
109
|
+
from datetime import datetime, timedelta, timezone
|
|
110
|
+
from .parser import load_records, CLAUDE_PROJECTS_DIR
|
|
111
|
+
root = Path(cfg["projects_dir"]) if cfg.get("projects_dir") else CLAUDE_PROJECTS_DIR
|
|
112
|
+
extra = [Path(p) for p in (cfg.get("extra_roots") or [])]
|
|
113
|
+
# since_hours: the quick report a fresh install shows while its first full
|
|
114
|
+
# read runs (only the recent logs, no parse cache).
|
|
115
|
+
since = (datetime.now(timezone.utc) - timedelta(hours=float(cfg["since_hours"]))
|
|
116
|
+
if cfg.get("since_hours") else None)
|
|
117
|
+
records, stats, intents, errors = load_records(root, extra_roots=extra, since=since,
|
|
118
|
+
progress=progress)
|
|
119
|
+
|
|
120
|
+
if progress:
|
|
121
|
+
progress({"phase": "building"})
|
|
122
|
+
report = build_report(records, plan=plan, user_intents=intents,
|
|
123
|
+
error_events=errors, source=source)
|
|
124
|
+
report["_meta"] = {**stats, "source": source}
|
|
125
|
+
if source == "codex" and stats.get("rate_limits"):
|
|
126
|
+
report["codex_rate_limits"] = stats["rate_limits"]
|
|
127
|
+
|
|
128
|
+
# Release before serializing (records no longer needed).
|
|
129
|
+
records = None
|
|
130
|
+
out = json.dumps(report, default=str)
|
|
131
|
+
dest = os.environ.get("BURNMETER_WORKER_OUT")
|
|
132
|
+
if dest:
|
|
133
|
+
Path(dest).write_text(out, encoding="utf-8")
|
|
134
|
+
else:
|
|
135
|
+
sys.stdout.write(out)
|
|
136
|
+
sys.stdout.flush()
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
sys.exit(main())
|
burnmeter/alerts.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Burnmeter — local, privacy-preserving pre-limit alerts.
|
|
2
|
+
|
|
3
|
+
Runs ENTIRELY on the user's machine: the local `burnmeter` process evaluates the
|
|
4
|
+
same thresholds the dashboard shows and pushes a notification to the user's OWN
|
|
5
|
+
webhook / Slack / email when a binding constraint is about to be hit. Nothing is
|
|
6
|
+
ever sent to a Burnmeter-operated server — the local-first promise holds (no
|
|
7
|
+
phone-home). This is what lets an alert reach you when the dashboard tab is
|
|
8
|
+
closed: the local server keeps evaluating in a background thread.
|
|
9
|
+
|
|
10
|
+
Threshold logic is a faithful port of dashboard.js `alertLevelOf`, so the
|
|
11
|
+
terminal/server alerts and the in-browser toast never disagree.
|
|
12
|
+
|
|
13
|
+
Config: ~/.config/burnmeter/alerts.json (chmod 600 — may hold SMTP creds).
|
|
14
|
+
Senders use only the Python standard library (urllib, smtplib).
|
|
15
|
+
|
|
16
|
+
Example alerts.json:
|
|
17
|
+
{
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"sources": ["claude", "codex"],
|
|
20
|
+
"destinations": {
|
|
21
|
+
"webhook_url": "https://example.com/hook",
|
|
22
|
+
"slack_webhook_url": "https://hooks.slack.com/services/...",
|
|
23
|
+
"email": {"host": "smtp.gmail.com", "port": 587, "tls": true,
|
|
24
|
+
"username": "you@gmail.com", "password": "app-password",
|
|
25
|
+
"from": "you@gmail.com", "to": "you@gmail.com"}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import smtplib
|
|
33
|
+
import ssl
|
|
34
|
+
import urllib.request
|
|
35
|
+
from email.message import EmailMessage
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
CONFIG_PATH = Path.home() / ".config" / "burnmeter" / "alerts.json"
|
|
40
|
+
|
|
41
|
+
# Thresholds — keep in sync with dashboard.js alertLevelOf.
|
|
42
|
+
RATE_LIMIT_WARN = 85 # % of account rate-limit (Codex)
|
|
43
|
+
RATE_LIMIT_CRIT = 95 # %
|
|
44
|
+
BURN_HEAVY_MULT = 1.6 # Claude: warn when burn-rate >= 'heavy' zone * this
|
|
45
|
+
WEEK_OVER_P95_MULT = 1.2 # budget pace: warn when this week's spend > heaviest week * this
|
|
46
|
+
|
|
47
|
+
LEVEL_NAMES = {0: "ok", 2: "warn", 3: "crit"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------- config ----------
|
|
51
|
+
def load_config(path: Path = CONFIG_PATH) -> dict:
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_config(cfg: dict, path: Path = CONFIG_PATH) -> None:
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
61
|
+
try:
|
|
62
|
+
path.chmod(0o600) # may contain an SMTP password
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_enabled(cfg: Optional[dict] = None) -> bool:
|
|
68
|
+
c = cfg if cfg is not None else load_config()
|
|
69
|
+
return bool(c.get("enabled")) and bool(c.get("destinations"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _money(n) -> str:
|
|
73
|
+
try:
|
|
74
|
+
return f"${float(n):,.0f}"
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return str(n)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------- evaluate (faithful port of dashboard.js alertLevelOf) ----------
|
|
80
|
+
def evaluate(report: dict, source: str) -> tuple[int, str]:
|
|
81
|
+
"""Return (level, message). level: 0 safe, 2 warn, 3 critical.
|
|
82
|
+
|
|
83
|
+
- Codex (has account rate-limits): max(primary, secondary) used_percent —
|
|
84
|
+
>=95 critical, >=85 warning.
|
|
85
|
+
- Claude (no account limit): burn-rate vs the 'heavy' reference zone —
|
|
86
|
+
>= heavy * 1.6 warning.
|
|
87
|
+
"""
|
|
88
|
+
level, msg = 0, ""
|
|
89
|
+
if source == "codex":
|
|
90
|
+
rls = report.get("codex_rate_limits") or {}
|
|
91
|
+
if rls:
|
|
92
|
+
dev = rls.get("mac") or next(iter(rls.values()), {}) or {}
|
|
93
|
+
primary = (dev.get("primary") or {}).get("used_percent") or 0
|
|
94
|
+
secondary = (dev.get("secondary") or {}).get("used_percent") or 0
|
|
95
|
+
bp = max(primary, secondary)
|
|
96
|
+
if bp >= RATE_LIMIT_CRIT:
|
|
97
|
+
level, msg = 3, f"\U0001f534 Codex limit at {round(bp)}% — throttle is IMMINENT"
|
|
98
|
+
elif bp >= RATE_LIMIT_WARN:
|
|
99
|
+
level, msg = 2, f"\U0001f7e0 Codex limit at {round(bp)}% — approaching the wall"
|
|
100
|
+
else:
|
|
101
|
+
# claude — no account limit, so we warn on excessive burn rate
|
|
102
|
+
fc = report.get("forecast") or {}
|
|
103
|
+
zones = report.get("burn_rate_zones") or {}
|
|
104
|
+
by_hours = fc.get("burn_rates_by_hours") or {}
|
|
105
|
+
rate = by_hours.get("2")
|
|
106
|
+
if rate is None:
|
|
107
|
+
rate = fc.get("burn_rate_per_hour_recent") or 0
|
|
108
|
+
heavy = zones.get("heavy")
|
|
109
|
+
if heavy and rate >= heavy * BURN_HEAVY_MULT:
|
|
110
|
+
level, msg = 2, f"\U0001f7e0 Burn rate is very high: {_money(rate)}/hr"
|
|
111
|
+
|
|
112
|
+
# Budget pace (any source) — the math was ALWAYS computed by analytics (weekly_cap +
|
|
113
|
+
# forecast.month) but never read here; this is the "you're on pace to overspend" alert.
|
|
114
|
+
# Fire only vs the user's OWN history (p95 of their heaviest week) — no invented budgets.
|
|
115
|
+
wc = report.get("weekly_cap") or {}
|
|
116
|
+
week = wc.get("rolling_7d_cost") or 0
|
|
117
|
+
p95 = wc.get("historical_7d_p95") or 0
|
|
118
|
+
if p95 > 0 and week >= p95 * WEEK_OVER_P95_MULT:
|
|
119
|
+
eom = ((report.get("forecast") or {}).get("month") or {}).get("projected_eom")
|
|
120
|
+
pace = (f"\U0001f4c8 Spending pace: {_money(week)} this week — "
|
|
121
|
+
f"{round(week / p95 * 100 - 100)}% above your heaviest week ({_money(p95)})")
|
|
122
|
+
if eom:
|
|
123
|
+
pace += f". On pace for {_money(eom)} this month."
|
|
124
|
+
if 2 > level:
|
|
125
|
+
level, msg = 2, pace
|
|
126
|
+
return level, msg
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------- senders (stdlib only) ----------
|
|
130
|
+
def send_webhook(url: str, payload: dict, timeout: int = 10) -> None:
|
|
131
|
+
data = json.dumps(payload).encode("utf-8")
|
|
132
|
+
req = urllib.request.Request(
|
|
133
|
+
url, data=data, method="POST",
|
|
134
|
+
headers={"Content-Type": "application/json", "User-Agent": "burnmeter-alerts"},
|
|
135
|
+
)
|
|
136
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
137
|
+
r.read()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def send_slack(url: str, text: str, timeout: int = 10) -> None:
|
|
141
|
+
# Slack Incoming Webhooks accept {"text": ...}.
|
|
142
|
+
send_webhook(url, {"text": text}, timeout=timeout)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def send_email(smtp: dict, subject: str, body: str) -> None:
|
|
146
|
+
msg = EmailMessage()
|
|
147
|
+
msg["Subject"] = subject
|
|
148
|
+
msg["From"] = smtp.get("from") or smtp.get("username") or "burnmeter@localhost"
|
|
149
|
+
msg["To"] = smtp["to"]
|
|
150
|
+
msg.set_content(body)
|
|
151
|
+
host = smtp.get("host", "localhost")
|
|
152
|
+
port = int(smtp.get("port", 587))
|
|
153
|
+
with smtplib.SMTP(host, port, timeout=20) as s:
|
|
154
|
+
if smtp.get("tls", True):
|
|
155
|
+
s.starttls(context=ssl.create_default_context())
|
|
156
|
+
if smtp.get("username"):
|
|
157
|
+
s.login(smtp["username"], smtp.get("password", ""))
|
|
158
|
+
s.send_message(msg)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def dispatch(cfg: dict, level: int, msg: str, source: str) -> list[str]:
|
|
162
|
+
"""Send `msg` to every configured destination. Never raises — returns a list
|
|
163
|
+
of "<dest>:ok" / "<dest>:err:<detail>" strings so callers can log results."""
|
|
164
|
+
dests = (cfg or {}).get("destinations") or {}
|
|
165
|
+
results: list[str] = []
|
|
166
|
+
subject = f"Burnmeter {LEVEL_NAMES.get(level, '')} · {source}"
|
|
167
|
+
if dests.get("webhook_url"):
|
|
168
|
+
try:
|
|
169
|
+
send_webhook(dests["webhook_url"], {
|
|
170
|
+
"source": source, "level": level,
|
|
171
|
+
"level_name": LEVEL_NAMES.get(level), "message": msg,
|
|
172
|
+
})
|
|
173
|
+
results.append("webhook:ok")
|
|
174
|
+
except Exception as e:
|
|
175
|
+
results.append(f"webhook:err:{e}")
|
|
176
|
+
if dests.get("slack_webhook_url"):
|
|
177
|
+
try:
|
|
178
|
+
send_slack(dests["slack_webhook_url"], msg)
|
|
179
|
+
results.append("slack:ok")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
results.append(f"slack:err:{e}")
|
|
182
|
+
if dests.get("email"):
|
|
183
|
+
try:
|
|
184
|
+
send_email(dests["email"], subject, msg)
|
|
185
|
+
results.append("email:ok")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
results.append(f"email:err:{e}")
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_and_fire(reports: dict, cfg: Optional[dict] = None,
|
|
192
|
+
state: Optional[dict] = None) -> dict:
|
|
193
|
+
"""Evaluate each source's report and dispatch on an UPWARD threshold crossing.
|
|
194
|
+
|
|
195
|
+
`reports`: {source: report_dict}. `state`: {source: last_level}, mutated and
|
|
196
|
+
returned. De-dup matches dashboard.js: fire only when level >= 2 AND it is
|
|
197
|
+
higher than the previous level for that source (so a sustained warning emits
|
|
198
|
+
once, and a warn->crit escalation re-fires).
|
|
199
|
+
"""
|
|
200
|
+
cfg = cfg if cfg is not None else load_config()
|
|
201
|
+
state = state if state is not None else {}
|
|
202
|
+
if not is_enabled(cfg):
|
|
203
|
+
return state
|
|
204
|
+
sources = cfg.get("sources") or list(reports.keys())
|
|
205
|
+
for source in sources:
|
|
206
|
+
rep = reports.get(source)
|
|
207
|
+
if not rep:
|
|
208
|
+
continue
|
|
209
|
+
level, msg = evaluate(rep, source)
|
|
210
|
+
prev = state.get(source, 0)
|
|
211
|
+
state[source] = level
|
|
212
|
+
if level >= 2 and level > prev:
|
|
213
|
+
dispatch(cfg, level, msg, source)
|
|
214
|
+
return state
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def send_test(cfg: Optional[dict] = None) -> list[str]:
|
|
218
|
+
"""Send a test alert to every configured destination (ignores enabled/level
|
|
219
|
+
gating) so users can verify their setup."""
|
|
220
|
+
cfg = cfg if cfg is not None else load_config()
|
|
221
|
+
return dispatch(cfg, 2,
|
|
222
|
+
"\U0001f514 Burnmeter test alert — your notification destinations work.",
|
|
223
|
+
"test")
|