cctally 1.23.0 → 1.25.0
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 +14 -0
- package/bin/_cctally_alerts.py +128 -24
- package/bin/_cctally_config.py +202 -11
- package/bin/_cctally_core.py +118 -0
- package/bin/_cctally_dashboard.py +193 -26
- package/bin/_cctally_forecast.py +480 -16
- package/bin/_cctally_milestones.py +146 -0
- package/bin/_cctally_parser.py +11 -4
- package/bin/_cctally_project.py +51 -0
- package/bin/_cctally_record.py +227 -1
- package/bin/_lib_alert_axes.py +21 -7
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +19 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
package/bin/_cctally_core.py
CHANGED
|
@@ -432,8 +432,14 @@ _ALERTS_CONFIG_VALID_KEYS = {
|
|
|
432
432
|
"weekly_thresholds",
|
|
433
433
|
"five_hour_thresholds",
|
|
434
434
|
"projected_enabled",
|
|
435
|
+
"notifier",
|
|
436
|
+
"command_template",
|
|
435
437
|
}
|
|
436
438
|
|
|
439
|
+
# Dispatch backends (Phase B). "auto" picks a platform default; "command"
|
|
440
|
+
# routes through alerts.command_template (which it then requires).
|
|
441
|
+
_ALERTS_VALID_NOTIFIERS = ("auto", "osascript", "notify-send", "command", "none")
|
|
442
|
+
|
|
437
443
|
|
|
438
444
|
def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
439
445
|
"""Validate one of the alerts threshold lists.
|
|
@@ -513,11 +519,47 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
513
519
|
f"alerts.projected_enabled must be a JSON boolean, got "
|
|
514
520
|
f"{type(projected_enabled).__name__}: {projected_enabled!r}"
|
|
515
521
|
)
|
|
522
|
+
# Dispatch-global keys (Phase B). `notifier` selects the backend;
|
|
523
|
+
# `command_template` is an argv list for the `command` backend (and may be
|
|
524
|
+
# set ahead of switching the backend). The cross-field constraint
|
|
525
|
+
# (notifier='command' requires a template) is enforced last.
|
|
526
|
+
notifier = block.get("notifier", "auto")
|
|
527
|
+
if notifier not in _ALERTS_VALID_NOTIFIERS:
|
|
528
|
+
raise _AlertsConfigError(
|
|
529
|
+
f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
|
|
530
|
+
f"got {notifier!r}"
|
|
531
|
+
)
|
|
532
|
+
command_template = block.get("command_template", None)
|
|
533
|
+
if command_template is not None:
|
|
534
|
+
if not isinstance(command_template, list) or not command_template:
|
|
535
|
+
raise _AlertsConfigError(
|
|
536
|
+
"alerts.command_template must be null or a non-empty list of strings"
|
|
537
|
+
)
|
|
538
|
+
for el in command_template:
|
|
539
|
+
if not isinstance(el, str):
|
|
540
|
+
raise _AlertsConfigError(
|
|
541
|
+
f"alerts.command_template elements must be strings, "
|
|
542
|
+
f"got {type(el).__name__}: {el!r}"
|
|
543
|
+
)
|
|
544
|
+
if "\x00" in el:
|
|
545
|
+
raise _AlertsConfigError(
|
|
546
|
+
"alerts.command_template elements must not contain a NUL byte"
|
|
547
|
+
)
|
|
548
|
+
if not command_template[0].strip():
|
|
549
|
+
raise _AlertsConfigError(
|
|
550
|
+
"alerts.command_template[0] (the program) must not be empty/whitespace"
|
|
551
|
+
)
|
|
552
|
+
if notifier == "command" and command_template is None:
|
|
553
|
+
raise _AlertsConfigError(
|
|
554
|
+
"alerts.notifier='command' requires alerts.command_template to be set"
|
|
555
|
+
)
|
|
516
556
|
return {
|
|
517
557
|
"enabled": enabled,
|
|
518
558
|
"weekly_thresholds": weekly,
|
|
519
559
|
"five_hour_thresholds": five_hour,
|
|
520
560
|
"projected_enabled": projected_enabled,
|
|
561
|
+
"notifier": notifier,
|
|
562
|
+
"command_template": command_template,
|
|
521
563
|
}
|
|
522
564
|
|
|
523
565
|
|
|
@@ -533,12 +575,16 @@ _BUDGET_DEFAULTS = {
|
|
|
533
575
|
"alerts_enabled": True, # "on when set"
|
|
534
576
|
"alert_thresholds": [90, 100],
|
|
535
577
|
"projected_enabled": False, # projected-pace opt-in (#121); default OFF
|
|
578
|
+
"projects": {}, # per-project weekly $ budgets, keyed by git-root
|
|
579
|
+
"project_alerts_enabled": False, # per-project alerts opt-in (#19/#121); default OFF
|
|
536
580
|
}
|
|
537
581
|
_BUDGET_CONFIG_VALID_KEYS = {
|
|
538
582
|
"weekly_usd",
|
|
539
583
|
"alerts_enabled",
|
|
540
584
|
"alert_thresholds",
|
|
541
585
|
"projected_enabled",
|
|
586
|
+
"projects",
|
|
587
|
+
"project_alerts_enabled",
|
|
542
588
|
}
|
|
543
589
|
|
|
544
590
|
|
|
@@ -551,6 +597,7 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
551
597
|
"""
|
|
552
598
|
out = dict(_BUDGET_DEFAULTS)
|
|
553
599
|
out["alert_thresholds"] = list(_BUDGET_DEFAULTS["alert_thresholds"])
|
|
600
|
+
out["projects"] = dict(_BUDGET_DEFAULTS["projects"])
|
|
554
601
|
block = cfg.get("budget") if isinstance(cfg, dict) else None
|
|
555
602
|
if block is None:
|
|
556
603
|
return out
|
|
@@ -606,6 +653,41 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
606
653
|
raise _BudgetConfigError("budget.projected_enabled must be a boolean")
|
|
607
654
|
out["projected_enabled"] = v
|
|
608
655
|
|
|
656
|
+
if "projects" in block:
|
|
657
|
+
v = block["projects"]
|
|
658
|
+
if not isinstance(v, dict):
|
|
659
|
+
raise _BudgetConfigError(
|
|
660
|
+
f"budget.projects must be an object, got {type(v).__name__}"
|
|
661
|
+
)
|
|
662
|
+
cleaned: "dict[str, float]" = {}
|
|
663
|
+
for proj_key, proj_val in v.items():
|
|
664
|
+
if not isinstance(proj_key, str):
|
|
665
|
+
raise _BudgetConfigError(
|
|
666
|
+
"budget.projects keys must be strings (canonical git-root paths)"
|
|
667
|
+
)
|
|
668
|
+
# Reuse the weekly_usd numeric rule per value: a non-bool finite
|
|
669
|
+
# number > 0 (bool is an int subclass, so reject it explicitly).
|
|
670
|
+
if isinstance(proj_val, bool) or not isinstance(proj_val, (int, float)):
|
|
671
|
+
raise _BudgetConfigError(
|
|
672
|
+
f"budget.projects values must be numbers, "
|
|
673
|
+
f"got {type(proj_val).__name__} for key {proj_key!r}"
|
|
674
|
+
)
|
|
675
|
+
if not math.isfinite(float(proj_val)) or float(proj_val) <= 0:
|
|
676
|
+
raise _BudgetConfigError(
|
|
677
|
+
f"budget.projects values must be finite numbers > 0, "
|
|
678
|
+
f"got {proj_val!r} for key {proj_key!r}"
|
|
679
|
+
)
|
|
680
|
+
cleaned[proj_key] = float(proj_val)
|
|
681
|
+
out["projects"] = cleaned
|
|
682
|
+
|
|
683
|
+
if "project_alerts_enabled" in block:
|
|
684
|
+
v = block["project_alerts_enabled"]
|
|
685
|
+
if not isinstance(v, bool):
|
|
686
|
+
raise _BudgetConfigError(
|
|
687
|
+
"budget.project_alerts_enabled must be a boolean"
|
|
688
|
+
)
|
|
689
|
+
out["project_alerts_enabled"] = v
|
|
690
|
+
|
|
609
691
|
return out
|
|
610
692
|
|
|
611
693
|
|
|
@@ -1085,6 +1167,42 @@ def open_db() -> sqlite3.Connection:
|
|
|
1085
1167
|
"""
|
|
1086
1168
|
)
|
|
1087
1169
|
|
|
1170
|
+
# ── project_budget_milestones (per-project equiv-$ budget crossings) ──────
|
|
1171
|
+
# Plain CREATE TABLE IF NOT EXISTS, NO migration handler / backfill — the
|
|
1172
|
+
# same posture as `budget_milestones` / `projected_milestones` (write-once,
|
|
1173
|
+
# forward-only, framework-untracked). `project_key` is the NEW dimension in
|
|
1174
|
+
# the UNIQUE key: each project crosses each threshold once per week,
|
|
1175
|
+
# independently of every other project (issue #19 / #121, spec §5.1). It
|
|
1176
|
+
# stores the canonical git-root (`ProjectKey.bucket_path`), matched by string
|
|
1177
|
+
# equality against each session entry's resolved git-root. `budget_usd`
|
|
1178
|
+
# snapshots the project's target AT crossing time so the dashboard renders
|
|
1179
|
+
# "$26 of $25" from the ROW, not from live config that may have changed since
|
|
1180
|
+
# (the Codex P0-4 lesson, already baked into `budget_milestones` /
|
|
1181
|
+
# `projected_milestones`). A mid-week quota reset re-anchors `week_start_at`
|
|
1182
|
+
# (new window → fresh rows under the UNIQUE key) — budget-pattern reset
|
|
1183
|
+
# handling, hence NO `reset_event_id` segment column. `alerted_at` is stamped
|
|
1184
|
+
# BEFORE dispatch (set-then-dispatch invariant); NULL = "recorded without
|
|
1185
|
+
# dispatch" (forward-only-from-set reconcile) OR "not yet dispatched", never
|
|
1186
|
+
# "delivery failed". Lives BEFORE the migration dispatcher: a plain CREATE on
|
|
1187
|
+
# a framework-untracked table never touches `schema_migrations`, so the
|
|
1188
|
+
# dispatcher's fresh-install snapshot is unaffected.
|
|
1189
|
+
conn.execute(
|
|
1190
|
+
"""
|
|
1191
|
+
CREATE TABLE IF NOT EXISTS project_budget_milestones (
|
|
1192
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1193
|
+
week_start_at TEXT NOT NULL,
|
|
1194
|
+
project_key TEXT NOT NULL, -- canonical git-root (bucket_path)
|
|
1195
|
+
threshold INTEGER NOT NULL,
|
|
1196
|
+
budget_usd REAL NOT NULL, -- project's target snapshotted AT crossing
|
|
1197
|
+
spent_usd REAL NOT NULL,
|
|
1198
|
+
consumption_pct REAL NOT NULL,
|
|
1199
|
+
crossed_at_utc TEXT NOT NULL,
|
|
1200
|
+
alerted_at TEXT,
|
|
1201
|
+
UNIQUE(week_start_at, project_key, threshold)
|
|
1202
|
+
)
|
|
1203
|
+
"""
|
|
1204
|
+
)
|
|
1205
|
+
|
|
1088
1206
|
# Migration framework dispatcher. Replaces the prior inline gate stack
|
|
1089
1207
|
# (has_blocks + _migration_done) with the framework's _run_pending_-
|
|
1090
1208
|
# migrations entry point. See spec §2.3, §5.2 + the migration handlers
|
|
@@ -340,6 +340,12 @@ def _build_alert_payload_projected(*args, **kwargs):
|
|
|
340
340
|
return sys.modules["cctally"]._build_alert_payload_projected(*args, **kwargs)
|
|
341
341
|
|
|
342
342
|
|
|
343
|
+
def _build_alert_payload_project_budget(*args, **kwargs):
|
|
344
|
+
return sys.modules["cctally"]._build_alert_payload_project_budget(
|
|
345
|
+
*args, **kwargs
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
343
349
|
def _dispatch_alert_notification(*args, **kwargs):
|
|
344
350
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
345
351
|
|
|
@@ -4252,6 +4258,72 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
|
|
|
4252
4258
|
return out
|
|
4253
4259
|
|
|
4254
4260
|
|
|
4261
|
+
def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
|
|
4262
|
+
# Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
|
|
4263
|
+
# crossings. Like the global budget axis, project-budget alerts re-anchor
|
|
4264
|
+
# ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
|
|
4265
|
+
# segment — the new window gets fresh rows under
|
|
4266
|
+
# ``UNIQUE(week_start_at, project_key, threshold)``. ``project_key`` is the
|
|
4267
|
+
# canonical git-root (``ProjectKey.bucket_path``); the human-readable chip
|
|
4268
|
+
# context carries the project BASENAME, resolved through the production
|
|
4269
|
+
# ``_resolve_project_key`` (git-root mode) so a moved/deleted repo still
|
|
4270
|
+
# renders its basename from the snapshotted path (no FS dependency on a
|
|
4271
|
+
# live ``.git``). ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` are
|
|
4272
|
+
# rendered FROM THE ROW (snapshotted at crossing), never live config that
|
|
4273
|
+
# may have changed since (Codex P0-4). The envelope id mirrors the dispatch
|
|
4274
|
+
# payload's ``project_budget:<week_start_at>:<project_key>:<threshold>``
|
|
4275
|
+
# shape (``_build_alert_payload_project_budget``).
|
|
4276
|
+
rows = conn.execute(
|
|
4277
|
+
f"""
|
|
4278
|
+
SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
|
|
4279
|
+
consumption_pct, crossed_at_utc, alerted_at
|
|
4280
|
+
FROM {descriptor.milestone_table}
|
|
4281
|
+
WHERE alerted_at IS NOT NULL
|
|
4282
|
+
ORDER BY alerted_at DESC
|
|
4283
|
+
LIMIT ?
|
|
4284
|
+
""",
|
|
4285
|
+
(limit,),
|
|
4286
|
+
).fetchall()
|
|
4287
|
+
c = _cctally()
|
|
4288
|
+
resolve = c._resolve_project_key
|
|
4289
|
+
resolver_cache: dict = {}
|
|
4290
|
+
# Collision-aware labels ACROSS the distinct project_keys in these rows so
|
|
4291
|
+
# two same-basename roots (/work/app + /personal/app) don't both render
|
|
4292
|
+
# "app" — byte-matching the live notification + budget table via the shared
|
|
4293
|
+
# `_project_disambiguate_labels`. Disambiguated across the ROWS (not live
|
|
4294
|
+
# config) to preserve the render-from-the-snapshotted-row invariant above.
|
|
4295
|
+
distinct_keys = sorted({r["project_key"] for r in rows})
|
|
4296
|
+
pkeys = [resolve(k, "git-root", resolver_cache) for k in distinct_keys]
|
|
4297
|
+
disambig = c._project_disambiguate_labels([{"key": pk} for pk in pkeys])
|
|
4298
|
+
label_by_key = {
|
|
4299
|
+
distinct_keys[i]: disambig.get(i, pkeys[i].display_key)
|
|
4300
|
+
for i in range(len(distinct_keys))
|
|
4301
|
+
}
|
|
4302
|
+
out: list[dict] = []
|
|
4303
|
+
for r in rows:
|
|
4304
|
+
threshold = int(r["threshold"])
|
|
4305
|
+
project_key = r["project_key"]
|
|
4306
|
+
out.append({
|
|
4307
|
+
"id": (
|
|
4308
|
+
f"project_budget:{r['week_start_at']}:{project_key}:{threshold}"
|
|
4309
|
+
),
|
|
4310
|
+
"axis": descriptor.id,
|
|
4311
|
+
"threshold": threshold,
|
|
4312
|
+
"severity": severity_for(threshold),
|
|
4313
|
+
"crossed_at": r["crossed_at_utc"],
|
|
4314
|
+
"alerted_at": r["alerted_at"],
|
|
4315
|
+
"context": {
|
|
4316
|
+
"week_start_at": r["week_start_at"],
|
|
4317
|
+
"project": label_by_key.get(project_key, project_key),
|
|
4318
|
+
"project_key": project_key,
|
|
4319
|
+
"budget_usd": float(r["budget_usd"]),
|
|
4320
|
+
"spent_usd": float(r["spent_usd"]),
|
|
4321
|
+
"consumption_pct": float(r["consumption_pct"]),
|
|
4322
|
+
},
|
|
4323
|
+
})
|
|
4324
|
+
return out
|
|
4325
|
+
|
|
4326
|
+
|
|
4255
4327
|
# Keyed by ``AlertAxisDescriptor.id`` — the registry decides which axes run,
|
|
4256
4328
|
# in what order; this table supplies the bespoke heterogeneous row-mapper.
|
|
4257
4329
|
_ENVELOPE_AXIS_MAPPERS = {
|
|
@@ -4259,6 +4331,7 @@ _ENVELOPE_AXIS_MAPPERS = {
|
|
|
4259
4331
|
"five_hour": _envelope_rows_five_hour,
|
|
4260
4332
|
"budget": _envelope_rows_budget,
|
|
4261
4333
|
"projected": _envelope_rows_projected,
|
|
4334
|
+
"project_budget": _envelope_rows_project_budget,
|
|
4262
4335
|
}
|
|
4263
4336
|
|
|
4264
4337
|
|
|
@@ -4269,19 +4342,20 @@ def _build_alerts_envelope_array(
|
|
|
4269
4342
|
"""Return the ``alerts`` array for the SSE snapshot envelope.
|
|
4270
4343
|
|
|
4271
4344
|
Union of ``percent_milestones``, ``five_hour_milestones``,
|
|
4272
|
-
``budget_milestones``,
|
|
4273
|
-
``alerted_at IS NOT NULL``,
|
|
4274
|
-
capped at ``limit`` (default 100).
|
|
4275
|
-
|
|
4276
|
-
100). Forward-only semantics: only
|
|
4277
|
-
get included; pre-deploy crossings
|
|
4278
|
-
invisible (spec §4.3).
|
|
4279
|
-
|
|
4280
|
-
All
|
|
4281
|
-
(``weekly`` / ``five_hour`` / ``budget`` / ``projected``
|
|
4282
|
-
The ``projected`` axis additionally
|
|
4283
|
-
(``weekly_pct`` | ``budget_usd``) so the
|
|
4284
|
-
metric-aware context renderer
|
|
4345
|
+
``budget_milestones``, ``projected_milestones``, and
|
|
4346
|
+
``project_budget_milestones`` rows with ``alerted_at IS NOT NULL``,
|
|
4347
|
+
ordered newest-first by ``alerted_at``, capped at ``limit`` (default 100).
|
|
4348
|
+
Single source of truth for both the dashboard panel (slices to 10
|
|
4349
|
+
client-side) and the modal (renders all 100). Forward-only semantics: only
|
|
4350
|
+
rows the alert-dispatch path stamped get included; pre-deploy crossings
|
|
4351
|
+
stay NULL and are intentionally invisible (spec §4.3).
|
|
4352
|
+
|
|
4353
|
+
All five axes share the same envelope schema; the ``axis`` field
|
|
4354
|
+
(``weekly`` / ``five_hour`` / ``budget`` / ``projected`` /
|
|
4355
|
+
``project_budget``) discriminates. The ``projected`` axis additionally
|
|
4356
|
+
carries a top-level ``metric`` (``weekly_pct`` | ``budget_usd``) so the
|
|
4357
|
+
frontend can pick its metric-aware context renderer; ``project_budget``
|
|
4358
|
+
carries the project basename + ``$spent of $budget`` in its context.
|
|
4285
4359
|
|
|
4286
4360
|
Per-axis ``LIMIT`` is applied at the SQL level (each query may yield
|
|
4287
4361
|
up to ``limit``) and the union is re-sorted + sliced — important for
|
|
@@ -4687,6 +4761,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4687
4761
|
"weekly_thresholds": [],
|
|
4688
4762
|
"five_hour_thresholds": [],
|
|
4689
4763
|
"projected_enabled": False,
|
|
4764
|
+
# Mirror the dispatch keys so the new alerts_settings lines
|
|
4765
|
+
# (`notifier` / `command_configured`) don't KeyError on a
|
|
4766
|
+
# corrupt config. Safe defaults: no notifier override, no
|
|
4767
|
+
# configured command.
|
|
4768
|
+
"notifier": "auto",
|
|
4769
|
+
"command_template": None,
|
|
4690
4770
|
}
|
|
4691
4771
|
# Budget is its OWN config block (issue #19) — source budget fields
|
|
4692
4772
|
# from ``_get_budget_config`` (the validated ``budget`` block), NOT
|
|
@@ -4696,7 +4776,8 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4696
4776
|
_budget_cfg = _get_budget_config(_cfg_for_alerts)
|
|
4697
4777
|
except _BudgetConfigError:
|
|
4698
4778
|
_budget_cfg = {"weekly_usd": None, "alerts_enabled": True,
|
|
4699
|
-
"alert_thresholds": [], "projected_enabled": False
|
|
4779
|
+
"alert_thresholds": [], "projected_enabled": False,
|
|
4780
|
+
"projects": {}, "project_alerts_enabled": False}
|
|
4700
4781
|
alerts_settings = {
|
|
4701
4782
|
"enabled": _alerts_cfg["enabled"],
|
|
4702
4783
|
"weekly_thresholds": list(_alerts_cfg["weekly_thresholds"]),
|
|
@@ -4708,6 +4789,21 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
4708
4789
|
# from the validated getters' ``projected_enabled`` (default False).
|
|
4709
4790
|
"projected_weekly_enabled": bool(_alerts_cfg.get("projected_enabled")),
|
|
4710
4791
|
"projected_budget_enabled": bool(_budget_cfg.get("projected_enabled")),
|
|
4792
|
+
# Per-project budget alerts opt-in mirror (issue #19 / #121). Gates the
|
|
4793
|
+
# ``project_budget`` axis dispatch only (the display section always
|
|
4794
|
+
# renders configured projects). Sourced from the validated budget
|
|
4795
|
+
# getter's ``project_alerts_enabled`` (default False) — the frontend
|
|
4796
|
+
# SettingsOverlay seeds a single on/off toggle from it.
|
|
4797
|
+
"project_alerts_enabled": bool(_budget_cfg.get("project_alerts_enabled")),
|
|
4798
|
+
# Alert-dispatch notifier mirror (Phase B). `notifier` is the
|
|
4799
|
+
# validated backend selector ("auto"/"command"/etc.). The raw
|
|
4800
|
+
# `command_template` is NEVER mirrored — it routinely holds secrets
|
|
4801
|
+
# (webhook URLs, bearer tokens) and the SSE snapshot is broadcast to
|
|
4802
|
+
# every connected client. We expose only a boolean: is a custom
|
|
4803
|
+
# command configured? (the CLI/config remains the sole writer of the
|
|
4804
|
+
# template itself).
|
|
4805
|
+
"notifier": _alerts_cfg.get("notifier", "auto"),
|
|
4806
|
+
"command_configured": _alerts_cfg.get("command_template") is not None,
|
|
4711
4807
|
}
|
|
4712
4808
|
|
|
4713
4809
|
# Mirror update-state.json + update-suppress.json into the envelope
|
|
@@ -5347,7 +5443,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5347
5443
|
sent) is the full computed block from ``_compute_display_block``
|
|
5348
5444
|
(preserves ``tz`` / ``resolved_tz`` / ``offset_label`` /
|
|
5349
5445
|
``offset_seconds`` shape consumers rely on). ``alerts`` (when
|
|
5350
|
-
sent) is the full validated block from ``_get_alerts_config
|
|
5446
|
+
sent) is the full validated block from ``_get_alerts_config``,
|
|
5447
|
+
except the raw ``command_template`` is redacted to the boolean
|
|
5448
|
+
``command_configured`` (it routinely holds secrets — webhook URLs
|
|
5449
|
+
/ bearer tokens — and the echo is returned to the client; the
|
|
5450
|
+
SSE ``alerts_settings`` mirror redacts identically). Do NOT
|
|
5451
|
+
re-add the raw template to the echo.
|
|
5351
5452
|
``saved_at`` is included for backward compat.
|
|
5352
5453
|
"""
|
|
5353
5454
|
if not self._check_origin_csrf():
|
|
@@ -5487,6 +5588,27 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5487
5588
|
{"error": "alerts.projected_enabled must be a JSON boolean"},
|
|
5488
5589
|
)
|
|
5489
5590
|
return
|
|
5591
|
+
# The dispatch command template is CLI/config-only — never
|
|
5592
|
+
# settable via the dashboard (it routinely holds secrets and the
|
|
5593
|
+
# dashboard echoes settings to the client). Reject it explicitly
|
|
5594
|
+
# rather than silently dropping it.
|
|
5595
|
+
if "command_template" in alerts_block:
|
|
5596
|
+
self._respond_json(
|
|
5597
|
+
400,
|
|
5598
|
+
{"error": "alerts.command_template is CLI/config-only "
|
|
5599
|
+
"(not settable via the dashboard)"},
|
|
5600
|
+
)
|
|
5601
|
+
return
|
|
5602
|
+
# `notifier` is settable (the backend selector). Structural type
|
|
5603
|
+
# check only; the enum + cross-field rule (command needs a stored
|
|
5604
|
+
# template) is enforced free by `_get_alerts_config(merged)` below.
|
|
5605
|
+
if "notifier" in alerts_block and not isinstance(
|
|
5606
|
+
alerts_block["notifier"], str
|
|
5607
|
+
):
|
|
5608
|
+
self._respond_json(
|
|
5609
|
+
400, {"error": "alerts.notifier must be a string"}
|
|
5610
|
+
)
|
|
5611
|
+
return
|
|
5490
5612
|
|
|
5491
5613
|
# Pre-validate budget shape (the structural type check; full
|
|
5492
5614
|
# cross-key validation runs inside the lock via
|
|
@@ -5602,6 +5724,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5602
5724
|
merged_alerts["projected_enabled"] = (
|
|
5603
5725
|
alerts_in["projected_enabled"]
|
|
5604
5726
|
)
|
|
5727
|
+
if "notifier" in alerts_in:
|
|
5728
|
+
merged_alerts["notifier"] = alerts_in["notifier"]
|
|
5605
5729
|
merged["alerts"] = merged_alerts
|
|
5606
5730
|
# Final cross-field validation against the merged block.
|
|
5607
5731
|
# _AlertsConfigError → 400 (no partial write since
|
|
@@ -5630,7 +5754,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5630
5754
|
budget_in = payload["budget"]
|
|
5631
5755
|
for leaf in (
|
|
5632
5756
|
"weekly_usd", "alerts_enabled", "alert_thresholds",
|
|
5633
|
-
"projected_enabled",
|
|
5757
|
+
"projected_enabled", "project_alerts_enabled",
|
|
5634
5758
|
):
|
|
5635
5759
|
if leaf in budget_in:
|
|
5636
5760
|
merged_budget[leaf] = budget_in[leaf]
|
|
@@ -5703,7 +5827,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5703
5827
|
merged, dt.datetime.now(dt.timezone.utc)
|
|
5704
5828
|
)
|
|
5705
5829
|
if "alerts" in payload:
|
|
5706
|
-
|
|
5830
|
+
# Echo the full validated alerts block (defaults filled) so the
|
|
5831
|
+
# SettingsOverlay can repaint without a follow-up GET — but
|
|
5832
|
+
# redact the raw `command_template` (secrets) the same way the
|
|
5833
|
+
# SSE snapshot mirror does: replace it with a boolean
|
|
5834
|
+
# `command_configured`.
|
|
5835
|
+
_a = dict(_get_alerts_config(merged))
|
|
5836
|
+
_a["command_configured"] = _a.pop("command_template", None) is not None
|
|
5837
|
+
out["alerts"] = _a
|
|
5707
5838
|
if "budget" in payload:
|
|
5708
5839
|
# Echo the full validated budget block (defaults filled) so the
|
|
5709
5840
|
# SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5716,7 +5847,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5716
5847
|
# Runs AFTER save_config (config persisted first); best-effort —
|
|
5717
5848
|
# never breaks the 200 response. Config write already left the
|
|
5718
5849
|
# config_writer_lock, so the helper's open_db never nests.
|
|
5719
|
-
|
|
5850
|
+
#
|
|
5851
|
+
# Gate each axis on the touched leaves (parity with `config set`):
|
|
5852
|
+
# running on an unrelated leaf would latch a currently-over-but-
|
|
5853
|
+
# not-yet-dispatched threshold, permanently suppressing the next
|
|
5854
|
+
# tick's dispatch. The dashboard accepts no `projects` leaf (the
|
|
5855
|
+
# map is CLI-only), so the per-project axis keys on
|
|
5856
|
+
# project_alerts_enabled/alert_thresholds.
|
|
5857
|
+
budget_in = payload["budget"]
|
|
5858
|
+
touched = (
|
|
5859
|
+
set(budget_in.keys()) if isinstance(budget_in, dict) else set()
|
|
5860
|
+
)
|
|
5861
|
+
if touched & {"weekly_usd", "alerts_enabled", "alert_thresholds"}:
|
|
5862
|
+
_cctally()._reconcile_budget_on_config_write(validated_budget)
|
|
5863
|
+
if touched & {"project_alerts_enabled", "alert_thresholds"}:
|
|
5864
|
+
_cctally()._reconcile_project_budget_milestones_on_write(
|
|
5865
|
+
validated_budget
|
|
5866
|
+
)
|
|
5720
5867
|
if update_check_validated is not None:
|
|
5721
5868
|
# Echo the full merged check block (cooked defaults included)
|
|
5722
5869
|
# so the SettingsOverlay can repaint without a follow-up GET.
|
|
@@ -5768,11 +5915,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5768
5915
|
dispatch status string in the JSON response.
|
|
5769
5916
|
|
|
5770
5917
|
Body (all fields optional): ``{"axis":
|
|
5771
|
-
"weekly"|"five_hour"|"budget"|"projected"
|
|
5772
|
-
"metric": "weekly_pct"|"budget_usd"}``. Defaults:
|
|
5773
|
-
threshold=90, metric="weekly_pct". ``metric`` is only
|
|
5774
|
-
the ``projected`` axis (mirrors the CLI ``alerts test
|
|
5775
|
-
projected --metric`` surface); it is ignored for the other
|
|
5918
|
+
"weekly"|"five_hour"|"budget"|"projected"|"project_budget",
|
|
5919
|
+
"threshold": 1..100, "metric": "weekly_pct"|"budget_usd"}``. Defaults:
|
|
5920
|
+
axis="weekly", threshold=90, metric="weekly_pct". ``metric`` is only
|
|
5921
|
+
consulted for the ``projected`` axis (mirrors the CLI ``alerts test
|
|
5922
|
+
--axis projected --metric`` surface); it is ignored for the other
|
|
5923
|
+
axes. The ``project_budget`` axis dispatches a synthetic example
|
|
5924
|
+
project ($26 of $25) — no real ``budget.projects`` entry required.
|
|
5776
5925
|
|
|
5777
5926
|
IMPORTANT: ``axis`` uses the underscore form (``"five_hour"``)
|
|
5778
5927
|
in the JSON API to match the dispatch payload's internal axis
|
|
@@ -5811,12 +5960,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5811
5960
|
return
|
|
5812
5961
|
|
|
5813
5962
|
axis = body.get("axis", "weekly")
|
|
5814
|
-
if axis not in (
|
|
5963
|
+
if axis not in (
|
|
5964
|
+
"weekly", "five_hour", "budget", "projected", "project_budget",
|
|
5965
|
+
):
|
|
5815
5966
|
self._respond_json(
|
|
5816
5967
|
400,
|
|
5817
5968
|
{"error": (
|
|
5818
|
-
"axis must be 'weekly', 'five_hour', 'budget'
|
|
5819
|
-
f"'projected', got {axis!r}"
|
|
5969
|
+
"axis must be 'weekly', 'five_hour', 'budget', "
|
|
5970
|
+
f"'projected' or 'project_budget', got {axis!r}"
|
|
5820
5971
|
)},
|
|
5821
5972
|
)
|
|
5822
5973
|
return
|
|
@@ -5867,6 +6018,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5867
6018
|
spent_usd=300.0 * threshold / 100.0,
|
|
5868
6019
|
consumption_pct=float(threshold),
|
|
5869
6020
|
)
|
|
6021
|
+
elif axis == "project_budget":
|
|
6022
|
+
# Synthetic per-project budget payload — mirrors the CLI
|
|
6023
|
+
# ``alerts test --axis project_budget`` branch (NO DB writes,
|
|
6024
|
+
# test/real divergence contract). Uses a fixed example project
|
|
6025
|
+
# ($26 of $25 = 104%) so no real ``budget.projects`` entry is
|
|
6026
|
+
# required; ``project_key`` is a placeholder canonical path.
|
|
6027
|
+
payload = _build_alert_payload_project_budget(
|
|
6028
|
+
threshold=threshold,
|
|
6029
|
+
crossed_at_utc=now_utc_iso(),
|
|
6030
|
+
week_start_at=dt.date.today().isoformat(),
|
|
6031
|
+
project="example-project",
|
|
6032
|
+
project_key="/example/repos/example-project",
|
|
6033
|
+
budget_usd=25.0,
|
|
6034
|
+
spent_usd=26.0,
|
|
6035
|
+
consumption_pct=104.0,
|
|
6036
|
+
)
|
|
5870
6037
|
elif axis == "projected":
|
|
5871
6038
|
# Synthetic projected-pace payload — mirrors the CLI
|
|
5872
6039
|
# cmd_alerts_test projected branch (NO DB writes, test/real
|