cctally 1.21.3 → 1.22.1
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.
- package/CHANGELOG.md +23 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_config.py +135 -0
- package/bin/_cctally_core.py +120 -0
- package/bin/_cctally_dashboard.py +155 -23
- package/bin/_cctally_db.py +3 -0
- package/bin/_cctally_parser.py +2541 -0
- package/bin/_cctally_record.py +148 -0
- package/bin/_cctally_share.py +1707 -0
- package/bin/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +74 -0
- package/bin/_lib_pricing.py +213 -13
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1263 -4266
- package/bin/cctally-budget +4 -0
- package/dashboard/static/assets/index-BxmaYT1y.css +1 -0
- package/dashboard/static/assets/index-CLcd-Tnm.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-BJ16SzRL.js +0 -18
- package/dashboard/static/assets/index-C1xH9GBW.css +0 -1
|
@@ -268,6 +268,9 @@ from _cctally_core import (
|
|
|
268
268
|
make_week_ref,
|
|
269
269
|
_get_alerts_config,
|
|
270
270
|
_AlertsConfigError,
|
|
271
|
+
_get_budget_config,
|
|
272
|
+
_budget_alerts_active,
|
|
273
|
+
_BudgetConfigError,
|
|
271
274
|
)
|
|
272
275
|
from _lib_display_tz import (
|
|
273
276
|
format_display_dt,
|
|
@@ -329,6 +332,10 @@ def _build_alert_payload_five_hour(*args, **kwargs):
|
|
|
329
332
|
return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
|
|
330
333
|
|
|
331
334
|
|
|
335
|
+
def _build_alert_payload_budget(*args, **kwargs):
|
|
336
|
+
return sys.modules["cctally"]._build_alert_payload_budget(*args, **kwargs)
|
|
337
|
+
|
|
338
|
+
|
|
332
339
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
333
340
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
334
341
|
|
|
@@ -4064,16 +4071,17 @@ def _build_alerts_envelope_array(
|
|
|
4064
4071
|
) -> list[dict]:
|
|
4065
4072
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4066
4073
|
|
|
4067
|
-
Union of ``percent_milestones
|
|
4068
|
-
with ``alerted_at IS NOT NULL``, ordered
|
|
4069
|
-
``alerted_at``, capped at ``limit`` (default 100).
|
|
4070
|
-
truth for both the dashboard panel (slices to 10
|
|
4071
|
-
the modal (renders all 100). Forward-only
|
|
4072
|
-
alert-dispatch path stamped get included;
|
|
4073
|
-
NULL and are intentionally invisible
|
|
4074
|
+
Union of ``percent_milestones``, ``five_hour_milestones``, and
|
|
4075
|
+
``budget_milestones`` rows with ``alerted_at IS NOT NULL``, ordered
|
|
4076
|
+
newest-first by ``alerted_at``, capped at ``limit`` (default 100).
|
|
4077
|
+
Single source of truth for both the dashboard panel (slices to 10
|
|
4078
|
+
client-side) and the modal (renders all 100). Forward-only
|
|
4079
|
+
semantics: only rows the alert-dispatch path stamped get included;
|
|
4080
|
+
pre-deploy crossings stay NULL and are intentionally invisible
|
|
4081
|
+
(spec §4.3).
|
|
4074
4082
|
|
|
4075
|
-
|
|
4076
|
-
discriminates.
|
|
4083
|
+
All three axes share the same envelope schema; the ``axis`` field
|
|
4084
|
+
(``weekly`` / ``five_hour`` / ``budget``) discriminates.
|
|
4077
4085
|
|
|
4078
4086
|
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4079
4087
|
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
@@ -4164,11 +4172,45 @@ def _build_alerts_envelope_array(
|
|
|
4164
4172
|
},
|
|
4165
4173
|
})
|
|
4166
4174
|
|
|
4175
|
+
# Third axis (issue #19): equiv-$ budget threshold crossings. Budget
|
|
4176
|
+
# alerts are keyed by the effective (post-reset) week_start_at + the
|
|
4177
|
+
# integer threshold; the envelope id mirrors the dispatch payload's
|
|
4178
|
+
# ``budget:<week_start_at>:<threshold>`` shape
|
|
4179
|
+
# (``_build_alert_payload_budget``). No ``reset_event_id`` segment —
|
|
4180
|
+
# a mid-week reset re-anchors ``week_start_at`` so the new window
|
|
4181
|
+
# naturally gets fresh rows under ``UNIQUE(week_start_at, threshold)``.
|
|
4182
|
+
budget_rows = conn.execute(
|
|
4183
|
+
"""
|
|
4184
|
+
SELECT week_start_at, threshold, crossed_at_utc, alerted_at,
|
|
4185
|
+
budget_usd, spent_usd, consumption_pct
|
|
4186
|
+
FROM budget_milestones
|
|
4187
|
+
WHERE alerted_at IS NOT NULL
|
|
4188
|
+
ORDER BY alerted_at DESC
|
|
4189
|
+
LIMIT ?
|
|
4190
|
+
""",
|
|
4191
|
+
(limit,),
|
|
4192
|
+
).fetchall()
|
|
4193
|
+
for r in budget_rows:
|
|
4194
|
+
threshold = int(r["threshold"])
|
|
4195
|
+
out.append({
|
|
4196
|
+
"id": f"budget:{r['week_start_at']}:{threshold}",
|
|
4197
|
+
"axis": "budget",
|
|
4198
|
+
"threshold": threshold,
|
|
4199
|
+
"crossed_at": r["crossed_at_utc"],
|
|
4200
|
+
"alerted_at": r["alerted_at"],
|
|
4201
|
+
"context": {
|
|
4202
|
+
"week_start_at": r["week_start_at"],
|
|
4203
|
+
"budget_usd": float(r["budget_usd"]),
|
|
4204
|
+
"spent_usd": float(r["spent_usd"]),
|
|
4205
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
4206
|
+
},
|
|
4207
|
+
})
|
|
4208
|
+
|
|
4167
4209
|
# Python's list.sort is stable. When two alerts share the same
|
|
4168
|
-
# `alerted_at` ISO string (rare;
|
|
4169
|
-
# millisecond), the union order (weekly
|
|
4170
|
-
# the tiebreaker — no extra deterministic key is added
|
|
4171
|
-
# spec doesn't require one.
|
|
4210
|
+
# `alerted_at` ISO string (rare; multiple axes firing within the same
|
|
4211
|
+
# millisecond), the union order (weekly, then 5h, then budget)
|
|
4212
|
+
# determines the tiebreaker — no extra deterministic key is added
|
|
4213
|
+
# because the spec doesn't require one.
|
|
4172
4214
|
out.sort(key=lambda a: a["alerted_at"], reverse=True)
|
|
4173
4215
|
return out[:limit]
|
|
4174
4216
|
|
|
@@ -4532,8 +4574,9 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4532
4574
|
# the entire snapshot — fall back to safe defaults and rely on
|
|
4533
4575
|
# `_warn_alerts_bad_config_once` for the user-visible signal.
|
|
4534
4576
|
alerts_array = list(getattr(snap, "alerts", []) or [])
|
|
4577
|
+
_cfg_for_alerts = load_config()
|
|
4535
4578
|
try:
|
|
4536
|
-
_alerts_cfg = _get_alerts_config(
|
|
4579
|
+
_alerts_cfg = _get_alerts_config(_cfg_for_alerts)
|
|
4537
4580
|
except _AlertsConfigError as exc:
|
|
4538
4581
|
_warn_alerts_bad_config_once(exc)
|
|
4539
4582
|
_alerts_cfg = {
|
|
@@ -4541,10 +4584,21 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4541
4584
|
"weekly_thresholds": [],
|
|
4542
4585
|
"five_hour_thresholds": [],
|
|
4543
4586
|
}
|
|
4587
|
+
# Budget is its OWN config block (issue #19) — source budget fields
|
|
4588
|
+
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
4589
|
+
# the ``alerts`` block. Defensive: a corrupt budget block must not
|
|
4590
|
+
# 500 the whole snapshot — fall back to "no budget / disabled".
|
|
4591
|
+
try:
|
|
4592
|
+
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
4593
|
+
except _BudgetConfigError:
|
|
4594
|
+
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
4595
|
+
"alert_thresholds": []}
|
|
4544
4596
|
alerts_settings = {
|
|
4545
4597
|
"enabled": _alerts_cfg["enabled"],
|
|
4546
4598
|
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
4547
4599
|
"five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
|
|
4600
|
+
"budget_thresholds": list(_budget_cfg["alert_thresholds"]),
|
|
4601
|
+
"budget_enabled": _budget_alerts_active(_budget_cfg),
|
|
4548
4602
|
}
|
|
4549
4603
|
|
|
4550
4604
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -5126,9 +5180,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5126
5180
|
|
|
5127
5181
|
Body shape: ``{"display"?: {"tz": "..."}, "alerts"?: {...},
|
|
5128
5182
|
"update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
|
|
5129
|
-
"cache_report"?: {"anomaly_threshold_pp"?: int}
|
|
5130
|
-
|
|
5131
|
-
|
|
5183
|
+
"cache_report"?: {"anomaly_threshold_pp"?: int},
|
|
5184
|
+
"budget"?: {"weekly_usd"?: number|null, "alerts_enabled"?: bool,
|
|
5185
|
+
"alert_thresholds"?: int[]}}`` — every top-level key is optional;
|
|
5186
|
+
any subset may be sent together (combined save). Unknown
|
|
5187
|
+
top-level keys are rejected with 400.
|
|
5132
5188
|
|
|
5133
5189
|
Per-block validation:
|
|
5134
5190
|
* ``display.tz`` — "local", "utc", or a valid IANA zone (via
|
|
@@ -5147,6 +5203,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5147
5203
|
``{error, field: "anomaly_threshold_pp"}`` on out-of-range
|
|
5148
5204
|
or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
|
|
5149
5205
|
F10 tracks lifting that.
|
|
5206
|
+
* ``budget`` — must be a dict; merged onto the persisted
|
|
5207
|
+
``budget`` block and validated via ``_get_budget_config(merged)``
|
|
5208
|
+
(issue #19); ``_BudgetConfigError`` → 400. Budget is its OWN
|
|
5209
|
+
config block, distinct from ``alerts``.
|
|
5150
5210
|
|
|
5151
5211
|
Atomic merged write: if all touched blocks validate, the merged
|
|
5152
5212
|
config is persisted in a single ``save_config`` call inside the
|
|
@@ -5198,7 +5258,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5198
5258
|
return
|
|
5199
5259
|
|
|
5200
5260
|
# Reject unknown top-level keys (forward-compat hygiene).
|
|
5201
|
-
allowed_top_keys = {
|
|
5261
|
+
allowed_top_keys = {
|
|
5262
|
+
"display", "alerts", "update", "cache_report", "budget",
|
|
5263
|
+
}
|
|
5202
5264
|
for k in payload.keys():
|
|
5203
5265
|
if k not in allowed_top_keys:
|
|
5204
5266
|
self._respond_json(
|
|
@@ -5212,12 +5274,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5212
5274
|
and "alerts" not in payload
|
|
5213
5275
|
and "update" not in payload
|
|
5214
5276
|
and "cache_report" not in payload
|
|
5277
|
+
and "budget" not in payload
|
|
5215
5278
|
):
|
|
5216
5279
|
self._respond_json(
|
|
5217
5280
|
400,
|
|
5218
5281
|
{"error": (
|
|
5219
5282
|
"body must contain at least one of: "
|
|
5220
|
-
"display, alerts, update, cache_report"
|
|
5283
|
+
"display, alerts, update, cache_report, budget"
|
|
5221
5284
|
)},
|
|
5222
5285
|
)
|
|
5223
5286
|
return
|
|
@@ -5305,6 +5368,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5305
5368
|
)
|
|
5306
5369
|
return
|
|
5307
5370
|
|
|
5371
|
+
# Pre-validate budget shape (the structural type check; full
|
|
5372
|
+
# cross-key validation runs inside the lock via
|
|
5373
|
+
# ``_get_budget_config(merged)``). Budget is its OWN config block
|
|
5374
|
+
# (issue #19), not part of ``alerts``.
|
|
5375
|
+
if "budget" in payload:
|
|
5376
|
+
budget_block = payload["budget"]
|
|
5377
|
+
if not isinstance(budget_block, dict):
|
|
5378
|
+
self._respond_json(
|
|
5379
|
+
400, {"error": "budget must be an object"}
|
|
5380
|
+
)
|
|
5381
|
+
return
|
|
5382
|
+
|
|
5308
5383
|
# Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
|
|
5309
5384
|
# is settable today; any other key under `update` or `update.check`
|
|
5310
5385
|
# is rejected so adding e.g. `update.banner.*` later is forward
|
|
@@ -5413,6 +5488,35 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5413
5488
|
self._respond_json(400, {"error": str(exc)})
|
|
5414
5489
|
return
|
|
5415
5490
|
|
|
5491
|
+
if "budget" in payload:
|
|
5492
|
+
# Same hand-edited-junk guard as alerts: a non-dict stored
|
|
5493
|
+
# ``budget`` block in config.json should surface as a
|
|
5494
|
+
# recoverable 400, not a 500. Budget is its OWN config
|
|
5495
|
+
# block (issue #19); the inbound keys are
|
|
5496
|
+
# ``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds``.
|
|
5497
|
+
existing_budget = merged.get("budget")
|
|
5498
|
+
if existing_budget is not None and not isinstance(
|
|
5499
|
+
existing_budget, dict
|
|
5500
|
+
):
|
|
5501
|
+
self._respond_json(
|
|
5502
|
+
400, {"error": "budget must be an object"}
|
|
5503
|
+
)
|
|
5504
|
+
return
|
|
5505
|
+
merged_budget = dict(existing_budget or {})
|
|
5506
|
+
budget_in = payload["budget"]
|
|
5507
|
+
for leaf in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
|
|
5508
|
+
if leaf in budget_in:
|
|
5509
|
+
merged_budget[leaf] = budget_in[leaf]
|
|
5510
|
+
merged["budget"] = merged_budget
|
|
5511
|
+
# Final validation against the merged block.
|
|
5512
|
+
# _BudgetConfigError → 400 (no partial write — save_config
|
|
5513
|
+
# has not yet been called).
|
|
5514
|
+
try:
|
|
5515
|
+
_get_budget_config(merged)
|
|
5516
|
+
except _BudgetConfigError as exc:
|
|
5517
|
+
self._respond_json(400, {"error": str(exc)})
|
|
5518
|
+
return
|
|
5519
|
+
|
|
5416
5520
|
if update_check_validated is not None:
|
|
5417
5521
|
# Same hand-edited-junk guard as alerts: a non-dict
|
|
5418
5522
|
# `update` or `update.check` block in config.json should
|
|
@@ -5473,6 +5577,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5473
5577
|
)
|
|
5474
5578
|
if "alerts" in payload:
|
|
5475
5579
|
out["alerts"] = _get_alerts_config(merged)
|
|
5580
|
+
if "budget" in payload:
|
|
5581
|
+
# Echo the full validated budget block (defaults filled) so the
|
|
5582
|
+
# SettingsOverlay can repaint without a follow-up GET.
|
|
5583
|
+
validated_budget = _get_budget_config(merged)
|
|
5584
|
+
out["budget"] = validated_budget
|
|
5585
|
+
# Forward-only reconcile (mirrors `budget set` / `config set
|
|
5586
|
+
# budget.*`): enabling/raising a budget while already past a
|
|
5587
|
+
# threshold records the crossed thresholds as already-alerted so
|
|
5588
|
+
# the next record-usage tick does NOT dispatch retroactive alerts.
|
|
5589
|
+
# Runs AFTER save_config (config persisted first); best-effort —
|
|
5590
|
+
# never breaks the 200 response. Config write already left the
|
|
5591
|
+
# config_writer_lock, so the helper's open_db never nests.
|
|
5592
|
+
_cctally()._reconcile_budget_on_config_write(validated_budget)
|
|
5476
5593
|
if update_check_validated is not None:
|
|
5477
5594
|
# Echo the full merged check block (cooked defaults included)
|
|
5478
5595
|
# so the SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5523,8 +5640,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5523
5640
|
``_dispatch_alert_notification(..., mode="test")``, returns the
|
|
5524
5641
|
dispatch status string in the JSON response.
|
|
5525
5642
|
|
|
5526
|
-
Body (all fields optional): ``{"axis":
|
|
5527
|
-
"threshold": 1..100}``. Defaults:
|
|
5643
|
+
Body (all fields optional): ``{"axis":
|
|
5644
|
+
"weekly"|"five_hour"|"budget", "threshold": 1..100}``. Defaults:
|
|
5645
|
+
axis="weekly", threshold=90.
|
|
5528
5646
|
|
|
5529
5647
|
IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
|
|
5530
5648
|
in the JSON API to match the dispatch payload's internal axis
|
|
@@ -5563,11 +5681,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5563
5681
|
return
|
|
5564
5682
|
|
|
5565
5683
|
axis = body.get("axis", "weekly")
|
|
5566
|
-
if axis not in ("weekly", "five_hour"):
|
|
5684
|
+
if axis not in ("weekly", "five_hour", "budget"):
|
|
5567
5685
|
self._respond_json(
|
|
5568
5686
|
400,
|
|
5569
5687
|
{"error": (
|
|
5570
|
-
|
|
5688
|
+
"axis must be 'weekly', 'five_hour' or 'budget', "
|
|
5689
|
+
f"got {axis!r}"
|
|
5571
5690
|
)},
|
|
5572
5691
|
)
|
|
5573
5692
|
return
|
|
@@ -5592,6 +5711,19 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5592
5711
|
cumulative_cost_usd=1.23,
|
|
5593
5712
|
dollars_per_percent=0.01,
|
|
5594
5713
|
)
|
|
5714
|
+
elif axis == "budget":
|
|
5715
|
+
# Synthetic budget payload — mirrors the CLI cmd_alerts_test
|
|
5716
|
+
# budget branch (NO DB writes, test/real divergence contract).
|
|
5717
|
+
# spent scaled to the threshold so the body reads plausibly
|
|
5718
|
+
# (e.g. 100% → $300 of $300).
|
|
5719
|
+
payload = _build_alert_payload_budget(
|
|
5720
|
+
threshold=threshold,
|
|
5721
|
+
crossed_at_utc=now_utc_iso(),
|
|
5722
|
+
week_start_at=dt.date.today().isoformat(),
|
|
5723
|
+
budget_usd=300.0,
|
|
5724
|
+
spent_usd=300.0 * threshold / 100.0,
|
|
5725
|
+
consumption_pct=float(threshold),
|
|
5726
|
+
)
|
|
5595
5727
|
else:
|
|
5596
5728
|
payload = _build_alert_payload_five_hour(
|
|
5597
5729
|
threshold=threshold,
|
package/bin/_cctally_db.py
CHANGED
|
@@ -1023,6 +1023,9 @@ _BANNER_SUPPRESSED_COMMANDS = frozenset({
|
|
|
1023
1023
|
"doctor", # consolidates migration + update banner state into its
|
|
1024
1024
|
# own report; double-printing the banner would duplicate
|
|
1025
1025
|
# findings doctor already surfaces structurally.
|
|
1026
|
+
"pricing-check", # read-only diagnostic emitting structured (often JSON)
|
|
1027
|
+
# output; banner noise pollutes the report + scripted
|
|
1028
|
+
# `--json` pipelines. Same posture as doctor.
|
|
1026
1029
|
"repair-symlinks", # invoked by npm postinstall; no banner during install
|
|
1027
1030
|
"blocks", # stdout-formatted table replacing `ccusage blocks`;
|
|
1028
1031
|
# stderr noise pollutes the visually-aligned report and
|