cctally 1.64.0 → 1.66.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 +50 -0
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +262 -40
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +49 -14
- package/bin/_cctally_dashboard.py +827 -4911
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +771 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +127 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +2 -1
- package/bin/_cctally_forecast.py +78 -135
- package/bin/_cctally_parser.py +919 -784
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +8 -5
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_tui.py +487 -254
- package/bin/_cctally_update.py +5 -0
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +34 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +18 -10
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +51 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +11 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
|
@@ -0,0 +1,2012 @@
|
|
|
1
|
+
"""Dashboard share feature (#279 S5 F1): shims + builders + handler impls.
|
|
2
|
+
|
|
3
|
+
Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
|
|
4
|
+
name below, so ``bin/cctally``'s re-exports and the share pytest files
|
|
5
|
+
(``tests/test_share_top_projects.py``, ``…_period_resolver.py``,
|
|
6
|
+
``…_v2_panel_ordering.py``) keep resolving unchanged (spec §2/§3).
|
|
7
|
+
|
|
8
|
+
What lives here (spec §3):
|
|
9
|
+
- the five share-CLI accessor shims (``_share_load_lib``, ``_share_now_utc``,
|
|
10
|
+
``_share_now_utc_iso``, ``_share_history_recipe_id``, ``_share_iso`` — each
|
|
11
|
+
still forwards late-binding to ``sys.modules["cctally"]``, so moving the
|
|
12
|
+
shim preserves the ``ns["X"]`` patch surface);
|
|
13
|
+
- ``_SHARE_POST_MAX_BYTES`` + the share-panel period constants
|
|
14
|
+
(``_SHARE_PANELS_PERIOD_FIXED`` / ``_SHARE_PANELS_PERIOD_OVERRIDABLE``);
|
|
15
|
+
the dashboard-bind validators stay in the dashboard;
|
|
16
|
+
- the share-period override pipeline + the per-panel share-data builders;
|
|
17
|
+
- the ten share handler methods as ``*_impl(handler, …)`` free functions
|
|
18
|
+
(``self.`` → ``handler.`` throughout; ``type(self)`` → ``type(handler)``),
|
|
19
|
+
the file's own ``_handle_get_project_detail_impl`` precedent. The
|
|
20
|
+
dashboard keeps ten thin bound delegators on ``DashboardHTTPHandler``.
|
|
21
|
+
|
|
22
|
+
Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
|
|
23
|
+
cctally-forwarding accessor shims the moved code called by bare name
|
|
24
|
+
(``load_config``, ``get_display_tz_pref``, ``config_writer_lock``, and
|
|
25
|
+
``c = _cctally()``) are inlined to their ``sys.modules["cctally"].X``
|
|
26
|
+
call-time reach — identical behavior, ns["X"] patch surface preserved (none
|
|
27
|
+
is dashboard-object-patched; audited). ``get_claude_session_entries`` is
|
|
28
|
+
reached at call time via ``sys.modules["_cctally_dashboard"]`` (spec §3 gate
|
|
29
|
+
/ plan Step 2): the share tests patch the cctally namespace, but the
|
|
30
|
+
dashboard-object reach is strictly stronger (it ALSO honors a dashboard-
|
|
31
|
+
module-object patch, which the rebuild-parity cache-report tests use) and
|
|
32
|
+
cycle-free.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import datetime as dt
|
|
37
|
+
import json
|
|
38
|
+
import pathlib
|
|
39
|
+
import sqlite3
|
|
40
|
+
import sys
|
|
41
|
+
from dataclasses import replace
|
|
42
|
+
from zoneinfo import ZoneInfo
|
|
43
|
+
|
|
44
|
+
from _cctally_core import open_db, parse_iso_datetime
|
|
45
|
+
from _cctally_config import save_config, _load_config_unlocked
|
|
46
|
+
from _lib_fmt import stable_sum
|
|
47
|
+
from _lib_pricing import _calculate_entry_cost
|
|
48
|
+
from _lib_five_hour import _canonical_5h_window_key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Share-CLI helpers consumed by the dashboard's share-data builders.
|
|
52
|
+
def _share_load_lib(*args, **kwargs):
|
|
53
|
+
return sys.modules["cctally"]._share_load_lib(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _share_now_utc(*args, **kwargs):
|
|
57
|
+
return sys.modules["cctally"]._share_now_utc(*args, **kwargs)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _share_now_utc_iso(*args, **kwargs):
|
|
61
|
+
return sys.modules["cctally"]._share_now_utc_iso(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _share_history_recipe_id(*args, **kwargs):
|
|
65
|
+
return sys.modules["cctally"]._share_history_recipe_id(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _share_iso(*args, **kwargs):
|
|
69
|
+
return sys.modules["cctally"]._share_iso(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# #279 S1 F3: cap on /api/share/* POST bodies. The share composer sends bigger
|
|
73
|
+
# payloads than the 4 KB settings POSTs (a multi-panel compose recipe), but
|
|
74
|
+
# must still be bounded — 64 KiB comfortably exceeds any real payload (render is
|
|
75
|
+
# one panel; compose is ~8 panels each with a small options recipe).
|
|
76
|
+
_SHARE_POST_MAX_BYTES = 64 * 1024
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# === Share-period override pipeline (dashboard-internal share helpers) =====
|
|
80
|
+
# Used by DashboardHTTPHandler's POST /api/share/render to rebuild a single
|
|
81
|
+
# panel's DataSnapshot against a shifted ``now_utc`` (kind=previous) or a
|
|
82
|
+
# custom date range (kind=custom). Pre-extract location: bin/cctally L13495.
|
|
83
|
+
|
|
84
|
+
_SHARE_PANELS_PERIOD_FIXED = ("forecast", "current-week", "sessions")
|
|
85
|
+
# Panels whose period is intrinsic to the panel's identity. We accept
|
|
86
|
+
# `kind="current"` (= no override) and reject anything else with 400.
|
|
87
|
+
|
|
88
|
+
_SHARE_PANELS_PERIOD_OVERRIDABLE = ("weekly", "daily", "monthly", "trend", "blocks")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _share_resolve_period(panel: str, options: dict):
|
|
92
|
+
"""Return (now_utc_override, start_override, error_dict) for the period.
|
|
93
|
+
|
|
94
|
+
- `(None, None, None)` — no override needed (period absent or
|
|
95
|
+
`kind="current"`). Caller continues with the cached DataSnapshot.
|
|
96
|
+
- `(datetime, None, None)` — `kind="previous"`. Caller rebuilds with
|
|
97
|
+
this `now_utc`; window length stays at the panel default.
|
|
98
|
+
- `(datetime, datetime, None)` — `kind="custom"`. Caller rebuilds
|
|
99
|
+
with `now_utc = end_dt` AND a derived window length spanning
|
|
100
|
+
`[start_dt, end_dt]` (computed by `_share_apply_period_override`
|
|
101
|
+
per panel). Spec §6.3 advertises "Custom (start–end pickers)";
|
|
102
|
+
honoring the start picker means the rendered window's left edge
|
|
103
|
+
moves with it. The 2-tuple form silently ignored `start_dt`.
|
|
104
|
+
- `(None, None, {...})` — validation failure; caller emits 400.
|
|
105
|
+
|
|
106
|
+
`parse_iso_datetime` (the same parser used by every other share
|
|
107
|
+
surface) accepts trailing `Z` / `+HH:MM` and naive forms. Naive
|
|
108
|
+
inputs are treated as UTC by `parse_iso_datetime` and downstream
|
|
109
|
+
UTC-fixup, so a date-only string like ``"2026-05-04"`` lands at
|
|
110
|
+
midnight UTC.
|
|
111
|
+
"""
|
|
112
|
+
period = options.get("period")
|
|
113
|
+
if period is None or not isinstance(period, dict):
|
|
114
|
+
# Absent → no override, defaults to current. (Permissive: the
|
|
115
|
+
# UI always sends a period block, but older basket recipes /
|
|
116
|
+
# CLI parity may omit it.)
|
|
117
|
+
return (None, None, None)
|
|
118
|
+
kind = period.get("kind", "current")
|
|
119
|
+
if kind not in ("current", "previous", "custom"):
|
|
120
|
+
return (None, None, {"error": f"unknown period kind: {kind!r}",
|
|
121
|
+
"field": "options.period.kind"})
|
|
122
|
+
if panel in _SHARE_PANELS_PERIOD_FIXED:
|
|
123
|
+
if kind != "current":
|
|
124
|
+
return (None, None, {
|
|
125
|
+
"error": (f"panel {panel!r} only supports period kind='current'; "
|
|
126
|
+
f"got {kind!r}"),
|
|
127
|
+
"field": "options.period.kind",
|
|
128
|
+
})
|
|
129
|
+
return (None, None, None)
|
|
130
|
+
# Overridable panels — handle each kind.
|
|
131
|
+
if kind == "current":
|
|
132
|
+
return (None, None, None)
|
|
133
|
+
if kind == "previous":
|
|
134
|
+
delta = _share_previous_period_delta(panel)
|
|
135
|
+
return (_share_now_utc() - delta, None, None)
|
|
136
|
+
# kind == "custom"
|
|
137
|
+
start_str = period.get("start")
|
|
138
|
+
end_str = period.get("end")
|
|
139
|
+
if not isinstance(start_str, str) or not start_str \
|
|
140
|
+
or not isinstance(end_str, str) or not end_str:
|
|
141
|
+
return (None, None, {
|
|
142
|
+
"error": "custom period requires non-empty start + end ISO dates",
|
|
143
|
+
"field": "options.period",
|
|
144
|
+
})
|
|
145
|
+
try:
|
|
146
|
+
start_dt = parse_iso_datetime(start_str, "options.period.start")
|
|
147
|
+
end_dt = parse_iso_datetime(end_str, "options.period.end")
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
return (None, None, {"error": f"invalid period date: {exc}",
|
|
150
|
+
"field": "options.period"})
|
|
151
|
+
if end_dt <= start_dt:
|
|
152
|
+
return (None, None, {
|
|
153
|
+
"error": ("custom period end must be strictly after start "
|
|
154
|
+
f"(got start={start_str!r}, end={end_str!r})"),
|
|
155
|
+
"field": "options.period",
|
|
156
|
+
})
|
|
157
|
+
return (end_dt, start_dt, None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _share_custom_window_n(panel: str, start_dt: "dt.datetime",
|
|
161
|
+
end_dt: "dt.datetime") -> int:
|
|
162
|
+
"""Per-panel window length covering `[start_dt, end_dt]`, min 1.
|
|
163
|
+
|
|
164
|
+
Each overridable panel exposes a different unit:
|
|
165
|
+
- weekly / trend → weeks
|
|
166
|
+
- daily → days (inclusive)
|
|
167
|
+
- monthly → calendar months (inclusive)
|
|
168
|
+
Blocks doesn't use this helper — its builder is window-anchored via
|
|
169
|
+
`week_start_at`/`week_end_at`, not `n`, so we pass `start_dt`/`end_dt`
|
|
170
|
+
directly to `_dashboard_build_blocks_panel`.
|
|
171
|
+
|
|
172
|
+
Inputs are timezone-aware UTC datetimes (`parse_iso_datetime` UTCs
|
|
173
|
+
naive inputs upstream). Math is purely on the timedelta + calendar
|
|
174
|
+
diffs; `_dashboard_build_monthly_periods` does its own display-tz
|
|
175
|
+
bucketing on the resulting window.
|
|
176
|
+
"""
|
|
177
|
+
import math as _math
|
|
178
|
+
delta_seconds = (end_dt - start_dt).total_seconds()
|
|
179
|
+
delta_days = _math.ceil(delta_seconds / 86400.0)
|
|
180
|
+
if panel in ("weekly", "trend"):
|
|
181
|
+
return max(1, _math.ceil(delta_days / 7))
|
|
182
|
+
if panel == "daily":
|
|
183
|
+
return max(1, int(delta_days))
|
|
184
|
+
if panel == "monthly":
|
|
185
|
+
months = ((end_dt.year - start_dt.year) * 12
|
|
186
|
+
+ (end_dt.month - start_dt.month) + 1)
|
|
187
|
+
return max(1, months)
|
|
188
|
+
# Shouldn't reach here — `_share_apply_period_override` handles
|
|
189
|
+
# blocks separately. Defensive: return 1 rather than raising.
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _share_previous_period_delta(panel: str) -> "dt.timedelta":
|
|
194
|
+
"""How far back `now_utc` shifts for `kind='previous'` on each panel.
|
|
195
|
+
|
|
196
|
+
weekly/daily: 7 days. monthly: one whole month worth (we shift to
|
|
197
|
+
the last day of the previous month at call time to handle variable
|
|
198
|
+
month length, so this is unused — the caller routes through
|
|
199
|
+
`_share_resolve_period` which special-cases monthly). trend: 8 weeks
|
|
200
|
+
(one trend window). blocks: 5 hours (one block).
|
|
201
|
+
"""
|
|
202
|
+
if panel == "weekly":
|
|
203
|
+
return dt.timedelta(days=7)
|
|
204
|
+
if panel == "daily":
|
|
205
|
+
return dt.timedelta(days=7)
|
|
206
|
+
if panel == "monthly":
|
|
207
|
+
return dt.timedelta(days=30) # close-enough for the resolver;
|
|
208
|
+
# see _share_resolve_period_monthly
|
|
209
|
+
# below for the calendar-aware
|
|
210
|
+
# version when needed.
|
|
211
|
+
if panel == "trend":
|
|
212
|
+
return dt.timedelta(days=8 * 7)
|
|
213
|
+
if panel == "blocks":
|
|
214
|
+
return dt.timedelta(hours=5)
|
|
215
|
+
raise ValueError(f"_share_previous_period_delta: no delta for panel {panel!r}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _share_apply_period_override(panel: str, options: dict,
|
|
219
|
+
snap: "DataSnapshot | None"):
|
|
220
|
+
"""Return (snap_or_None, error_dict_or_None).
|
|
221
|
+
|
|
222
|
+
Walks `_share_resolve_period`, then re-builds the panel's DataSnapshot
|
|
223
|
+
field from DB when an override is requested. `dataclasses.replace`
|
|
224
|
+
yields a shallow copy with one field swapped. Returns the original
|
|
225
|
+
`snap` unchanged when no override applies.
|
|
226
|
+
"""
|
|
227
|
+
if snap is None:
|
|
228
|
+
# No cached snapshot to override against — return None unchanged
|
|
229
|
+
# and let the panel_data builder's empty-snapshot path handle it.
|
|
230
|
+
# Still validate the period option so the user gets a 400 on
|
|
231
|
+
# malformed input even before the sync thread's first tick.
|
|
232
|
+
_, _, err = _share_resolve_period(panel, options)
|
|
233
|
+
return (snap, err)
|
|
234
|
+
now_override, start_override, err = _share_resolve_period(panel, options)
|
|
235
|
+
if err is not None:
|
|
236
|
+
return (None, err)
|
|
237
|
+
if now_override is None:
|
|
238
|
+
return (snap, None)
|
|
239
|
+
# For `kind="custom"`, derive a per-panel window length covering
|
|
240
|
+
# `[start_override, now_override]` so the rendered window honors the
|
|
241
|
+
# Start picker (spec §6.3). For `kind="previous"`, `start_override`
|
|
242
|
+
# is None → window length stays at the panel's default.
|
|
243
|
+
n_override = (
|
|
244
|
+
_share_custom_window_n(panel, start_override, now_override)
|
|
245
|
+
if start_override is not None else None
|
|
246
|
+
)
|
|
247
|
+
import dataclasses as _dc
|
|
248
|
+
# Cross-module accessor — moved-function calls that are ALSO
|
|
249
|
+
# monkeypatched in tests (``_dashboard_build_*``, ``_tui_build_trend``)
|
|
250
|
+
# must resolve through cctally's namespace so ``monkeypatch.setitem(ns,
|
|
251
|
+
# "_dashboard_build_weekly_periods", spy)`` propagates here per spec §5.6.
|
|
252
|
+
c = sys.modules["cctally"]
|
|
253
|
+
conn = open_db()
|
|
254
|
+
try:
|
|
255
|
+
if panel == "weekly":
|
|
256
|
+
kwargs: dict = {"skip_sync": True}
|
|
257
|
+
if n_override is not None:
|
|
258
|
+
kwargs["n"] = n_override
|
|
259
|
+
rows = c._dashboard_build_weekly_periods(conn, now_override, **kwargs)
|
|
260
|
+
return (_dc.replace(snap, weekly_periods=rows), None)
|
|
261
|
+
if panel == "daily":
|
|
262
|
+
display_tz_name = options.get("display_tz", "Etc/UTC")
|
|
263
|
+
try:
|
|
264
|
+
display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
|
|
265
|
+
except Exception:
|
|
266
|
+
display_tz = None
|
|
267
|
+
kwargs = {"skip_sync": True, "display_tz": display_tz}
|
|
268
|
+
if n_override is not None:
|
|
269
|
+
kwargs["n"] = n_override
|
|
270
|
+
rows = c._dashboard_build_daily_panel(conn, now_override, **kwargs)
|
|
271
|
+
return (_dc.replace(snap, daily_panel=rows), None)
|
|
272
|
+
if panel == "monthly":
|
|
273
|
+
kwargs = {"skip_sync": True}
|
|
274
|
+
if n_override is not None:
|
|
275
|
+
kwargs["n"] = n_override
|
|
276
|
+
rows = c._dashboard_build_monthly_periods(conn, now_override, **kwargs)
|
|
277
|
+
return (_dc.replace(snap, monthly_periods=rows), None)
|
|
278
|
+
if panel == "trend":
|
|
279
|
+
kwargs = {"skip_sync": True}
|
|
280
|
+
if n_override is not None:
|
|
281
|
+
kwargs["count"] = n_override
|
|
282
|
+
rows = c._tui_build_trend(conn, now_override, **kwargs)
|
|
283
|
+
return (_dc.replace(snap, trend=rows), None)
|
|
284
|
+
if panel == "blocks":
|
|
285
|
+
# `_dashboard_build_blocks_panel` is window-anchored via
|
|
286
|
+
# `week_start_at`/`week_end_at`, not `n`. For `kind='custom'`,
|
|
287
|
+
# use the user's [start_dt, end_dt] verbatim. For
|
|
288
|
+
# `kind='previous'`, fall back to a 7-day window ending at
|
|
289
|
+
# the override `now_utc` (the spec's prior-block semantics —
|
|
290
|
+
# intentionally NOT aligned to subscription-week boundaries
|
|
291
|
+
# since the share period override is wall-clock-aware, not
|
|
292
|
+
# quota-aware).
|
|
293
|
+
if start_override is not None:
|
|
294
|
+
week_start_at = start_override
|
|
295
|
+
week_end_at = now_override
|
|
296
|
+
else:
|
|
297
|
+
week_start_at = now_override - dt.timedelta(days=7)
|
|
298
|
+
week_end_at = now_override
|
|
299
|
+
rows = c._dashboard_build_blocks_panel(
|
|
300
|
+
conn, now_override,
|
|
301
|
+
week_start_at=week_start_at,
|
|
302
|
+
week_end_at=week_end_at,
|
|
303
|
+
skip_sync=True,
|
|
304
|
+
)
|
|
305
|
+
return (_dc.replace(snap, blocks_panel=rows), None)
|
|
306
|
+
# forecast / current-week / sessions: resolver already gated; we
|
|
307
|
+
# only reach here for `kind="current"`, which returns no
|
|
308
|
+
# override.
|
|
309
|
+
return (snap, None)
|
|
310
|
+
finally:
|
|
311
|
+
conn.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _share_apply_content_toggles(snap_built, options: dict):
|
|
315
|
+
"""Strip chart / table from a built ShareSnapshot per render options.
|
|
316
|
+
|
|
317
|
+
The render kernel consumes whatever the template builder emits, so
|
|
318
|
+
chart/table on-off can't be expressed by the builder alone (every
|
|
319
|
+
builder unconditionally emits both). Apply the toggle here, after
|
|
320
|
+
the builder, before `_scrub` and `render`. ShareSnapshot is frozen;
|
|
321
|
+
`dataclasses.replace` returns a new instance.
|
|
322
|
+
|
|
323
|
+
Defaults preserve pre-toggle behavior: `show_chart` defaults to
|
|
324
|
+
True, `show_table` defaults to True. Explicit False on either
|
|
325
|
+
drops the corresponding payload.
|
|
326
|
+
"""
|
|
327
|
+
import dataclasses as _dc
|
|
328
|
+
show_chart = bool(options.get("show_chart", True))
|
|
329
|
+
show_table = bool(options.get("show_table", True))
|
|
330
|
+
changes: dict = {}
|
|
331
|
+
if not show_chart:
|
|
332
|
+
changes["chart"] = None
|
|
333
|
+
if not show_table:
|
|
334
|
+
changes["columns"] = ()
|
|
335
|
+
changes["rows"] = ()
|
|
336
|
+
if not changes:
|
|
337
|
+
return snap_built
|
|
338
|
+
return _dc.replace(snap_built, **changes)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Cap on how many `(project, cost)` rows builders return for top_projects.
|
|
342
|
+
# Templates take `top_n` from options (default 5, see _lib_share_templates)
|
|
343
|
+
# and apply their own cap on top of this. The headroom matters because:
|
|
344
|
+
# (a) the scrubber walks ProjectCells once per row, so unbounded length
|
|
345
|
+
# balloons render-time anonymization cost;
|
|
346
|
+
# (b) the live preview iframe streams the full table chrome;
|
|
347
|
+
# (c) 20 covers any realistic `top_n` knob value (UI typically caps at 10).
|
|
348
|
+
_SHARE_TOP_PROJECTS_BUILDER_CAP = 20
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _share_top_projects_for_range(
|
|
352
|
+
range_start: "dt.datetime",
|
|
353
|
+
range_end: "dt.datetime",
|
|
354
|
+
*,
|
|
355
|
+
skip_sync: bool = True,
|
|
356
|
+
) -> list[tuple[str, float]]:
|
|
357
|
+
"""Aggregate session_entries in `[range_start, range_end]` by project_path.
|
|
358
|
+
|
|
359
|
+
Returns `[(project_path_or_'(unknown)', cost_usd), ...]` sorted desc by
|
|
360
|
+
cost and capped at `_SHARE_TOP_PROJECTS_BUILDER_CAP`. Templates apply
|
|
361
|
+
a further `top_n` cap (default 5).
|
|
362
|
+
|
|
363
|
+
Routes through `get_claude_session_entries` so we get `project_path`
|
|
364
|
+
in the join — same cache-first/lock-contention/direct-JSONL fallback
|
|
365
|
+
chain the rest of the share path relies on. `skip_sync=True` by
|
|
366
|
+
default: the sync thread has already done its tick at snapshot-build
|
|
367
|
+
time, and a per-request ingest would block the share render on
|
|
368
|
+
`cache.db.lock`.
|
|
369
|
+
|
|
370
|
+
Cost computation goes through `_calculate_entry_cost` — the
|
|
371
|
+
single-source-of-truth pricing path. Mirrors `_compute_block_totals`'
|
|
372
|
+
`by_project` bucketing exactly, so the reconcile invariant
|
|
373
|
+
`SUM(top_projects) ≈ panel.cost_usd` is preserved within ULP drift
|
|
374
|
+
when the panel's cost matches the same time range (e.g., current
|
|
375
|
+
week, current 5h block).
|
|
376
|
+
|
|
377
|
+
NULL `project_path` collapses to the `(unknown)` sentinel. Anon
|
|
378
|
+
happens later in `_scrub()`; builders always emit real names per
|
|
379
|
+
the kernel's privacy chokepoint contract.
|
|
380
|
+
"""
|
|
381
|
+
bucket: dict[str, float] = {}
|
|
382
|
+
try:
|
|
383
|
+
# late-binding: reach the dashboard module object so both the ns-patch (share tests)
|
|
384
|
+
# and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
|
|
385
|
+
entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
|
|
386
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
387
|
+
)
|
|
388
|
+
except Exception:
|
|
389
|
+
# `get_claude_session_entries` already has its own fallback chain,
|
|
390
|
+
# but if even that fails (e.g., HOME unset in a fixture run with
|
|
391
|
+
# no monkeypatch), don't break the whole share render — just emit
|
|
392
|
+
# an empty top_projects.
|
|
393
|
+
return []
|
|
394
|
+
for entry in entries:
|
|
395
|
+
usage = {
|
|
396
|
+
"input_tokens": entry.input_tokens,
|
|
397
|
+
"output_tokens": entry.output_tokens,
|
|
398
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
399
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
400
|
+
}
|
|
401
|
+
cost = _calculate_entry_cost(
|
|
402
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
403
|
+
)
|
|
404
|
+
key = entry.project_path or "(unknown)"
|
|
405
|
+
bucket[key] = bucket.get(key, 0.0) + cost
|
|
406
|
+
ranked = sorted(bucket.items(), key=lambda kv: -kv[1])
|
|
407
|
+
return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _share_all_projects_for_range(
|
|
411
|
+
range_start: "dt.datetime",
|
|
412
|
+
range_end: "dt.datetime",
|
|
413
|
+
*,
|
|
414
|
+
skip_sync: bool = True,
|
|
415
|
+
) -> dict[str, float]:
|
|
416
|
+
"""Like `_share_top_projects_for_range` but uncapped and unsorted.
|
|
417
|
+
|
|
418
|
+
Returns {project_path_or_'(unknown)': cost_usd} for every project
|
|
419
|
+
active in the range. Caller orders or caps as needed. Used by
|
|
420
|
+
`_share_per_block_per_project`'s fallback path so the fallback's
|
|
421
|
+
accuracy matches the canonical rollup-table path (spec §7.2.1,
|
|
422
|
+
issue #33).
|
|
423
|
+
"""
|
|
424
|
+
bucket: dict[str, float] = {}
|
|
425
|
+
try:
|
|
426
|
+
# late-binding: reach the dashboard module object so both the ns-patch (share tests)
|
|
427
|
+
# and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
|
|
428
|
+
entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
|
|
429
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
430
|
+
)
|
|
431
|
+
except Exception:
|
|
432
|
+
return bucket
|
|
433
|
+
for entry in entries:
|
|
434
|
+
usage = {
|
|
435
|
+
"input_tokens": entry.input_tokens,
|
|
436
|
+
"output_tokens": entry.output_tokens,
|
|
437
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
438
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
439
|
+
}
|
|
440
|
+
cost = _calculate_entry_cost(
|
|
441
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
442
|
+
)
|
|
443
|
+
key = entry.project_path or "(unknown)"
|
|
444
|
+
bucket[key] = bucket.get(key, 0.0) + cost
|
|
445
|
+
return bucket
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _share_per_day_per_project_for_range(
|
|
449
|
+
range_start: "dt.datetime",
|
|
450
|
+
range_end: "dt.datetime",
|
|
451
|
+
*,
|
|
452
|
+
display_tz: str,
|
|
453
|
+
skip_sync: bool = True,
|
|
454
|
+
) -> dict[str, dict[str, float]]:
|
|
455
|
+
"""Aggregate session_entries in [range_start, range_end] by
|
|
456
|
+
(day-in-display_tz, project_path).
|
|
457
|
+
|
|
458
|
+
Returns {date_str: {project_path_or_'(unknown)': cost_usd}}. Same
|
|
459
|
+
cache-first/lock-contention/direct-JSONL fallback chain as
|
|
460
|
+
`_share_top_projects_for_range`. Day bucket computed in display_tz
|
|
461
|
+
so the rendered row label matches. Issue #33.
|
|
462
|
+
"""
|
|
463
|
+
try:
|
|
464
|
+
tz = ZoneInfo(display_tz) if display_tz else dt.timezone.utc
|
|
465
|
+
except Exception:
|
|
466
|
+
tz = dt.timezone.utc
|
|
467
|
+
out: dict[str, dict[str, float]] = {}
|
|
468
|
+
try:
|
|
469
|
+
# late-binding: reach the dashboard module object so both the ns-patch (share tests)
|
|
470
|
+
# and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
|
|
471
|
+
entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
|
|
472
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
473
|
+
)
|
|
474
|
+
except Exception:
|
|
475
|
+
return out
|
|
476
|
+
for entry in entries:
|
|
477
|
+
usage = {
|
|
478
|
+
"input_tokens": entry.input_tokens,
|
|
479
|
+
"output_tokens": entry.output_tokens,
|
|
480
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
481
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
482
|
+
}
|
|
483
|
+
cost = _calculate_entry_cost(
|
|
484
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
485
|
+
)
|
|
486
|
+
day = entry.timestamp.astimezone(tz).strftime("%Y-%m-%d")
|
|
487
|
+
proj = entry.project_path or "(unknown)"
|
|
488
|
+
out.setdefault(day, {})
|
|
489
|
+
out[day][proj] = out[day].get(proj, 0.0) + cost
|
|
490
|
+
return out
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _share_per_block_per_project(
|
|
494
|
+
recent_blocks: list[dict],
|
|
495
|
+
) -> dict[str, dict[str, float]]:
|
|
496
|
+
"""Aggregate per-block per-project costs from `five_hour_block_projects`.
|
|
497
|
+
|
|
498
|
+
Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
|
|
499
|
+
Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
|
|
500
|
+
(10-min floor; same chokepoint as `maybe_update_five_hour_block`,
|
|
501
|
+
per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
|
|
502
|
+
|
|
503
|
+
Fallback (rollup empty/unreadable): per-block sweep over
|
|
504
|
+
`_share_all_projects_for_range` — uncapped, accuracy parity with the
|
|
505
|
+
canonical path. Fires only during the first tick after fresh install
|
|
506
|
+
or before stats-migration `002_five_hour_block_projects_backfill_v1`
|
|
507
|
+
completes. Issue #33.
|
|
508
|
+
"""
|
|
509
|
+
if not recent_blocks:
|
|
510
|
+
return {}
|
|
511
|
+
out: dict[str, dict[str, float]] = {}
|
|
512
|
+
keys: list[int] = []
|
|
513
|
+
iso_by_key: dict[int, str] = {}
|
|
514
|
+
for b in recent_blocks:
|
|
515
|
+
try:
|
|
516
|
+
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
517
|
+
except (ValueError, KeyError):
|
|
518
|
+
continue
|
|
519
|
+
wk = _canonical_5h_window_key(int(ts.timestamp()))
|
|
520
|
+
keys.append(wk)
|
|
521
|
+
iso_by_key[wk] = b["start_at"]
|
|
522
|
+
if not keys:
|
|
523
|
+
return out
|
|
524
|
+
try:
|
|
525
|
+
conn = open_db()
|
|
526
|
+
placeholders = ",".join("?" for _ in keys)
|
|
527
|
+
rows = conn.execute(
|
|
528
|
+
f"SELECT five_hour_window_key, project_path, cost_usd "
|
|
529
|
+
f"FROM five_hour_block_projects "
|
|
530
|
+
f"WHERE five_hour_window_key IN ({placeholders})",
|
|
531
|
+
keys,
|
|
532
|
+
).fetchall()
|
|
533
|
+
for wk, project_path, cost in rows:
|
|
534
|
+
block_iso = iso_by_key.get(wk)
|
|
535
|
+
if block_iso is None:
|
|
536
|
+
continue
|
|
537
|
+
proj = project_path or "(unknown)"
|
|
538
|
+
out.setdefault(block_iso, {})
|
|
539
|
+
out[block_iso][proj] = out[block_iso].get(proj, 0.0) + float(cost)
|
|
540
|
+
if out:
|
|
541
|
+
return out
|
|
542
|
+
except (sqlite3.DatabaseError, OSError):
|
|
543
|
+
pass
|
|
544
|
+
# Fallback: per-block uncapped session_entries sweep.
|
|
545
|
+
for b in recent_blocks:
|
|
546
|
+
try:
|
|
547
|
+
ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
|
|
548
|
+
except (ValueError, KeyError):
|
|
549
|
+
continue
|
|
550
|
+
end = ts + dt.timedelta(hours=5)
|
|
551
|
+
out[b["start_at"]] = sys.modules["cctally"]._share_all_projects_for_range(ts, end)
|
|
552
|
+
return out
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _build_share_panel_data(panel: str, options: dict,
|
|
556
|
+
snap: "DataSnapshot | None") -> dict:
|
|
557
|
+
"""Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
|
|
558
|
+
|
|
559
|
+
Each per-panel builder reads from the already-built `DataSnapshot`
|
|
560
|
+
rather than re-running CLI aggregation queries — keeps /api/share/render
|
|
561
|
+
cheap and ensures the share artifact matches what the dashboard panel
|
|
562
|
+
is currently showing.
|
|
563
|
+
"""
|
|
564
|
+
if panel == "weekly": return _build_weekly_share_panel_data(options, snap)
|
|
565
|
+
if panel == "daily": return _build_daily_share_panel_data(options, snap)
|
|
566
|
+
if panel == "monthly": return _build_monthly_share_panel_data(options, snap)
|
|
567
|
+
if panel == "trend": return _build_trend_share_panel_data(options, snap)
|
|
568
|
+
if panel == "forecast": return _build_forecast_share_panel_data(options, snap)
|
|
569
|
+
if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
|
|
570
|
+
if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
|
|
571
|
+
if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
|
|
572
|
+
if panel == "projects": return _build_projects_share_panel_data(options, snap)
|
|
573
|
+
raise ValueError(f"unknown share panel: {panel!r}")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _share_empty_week_stub() -> dict:
|
|
577
|
+
"""Minimal week shape so empty snapshots render as "no data" cleanly.
|
|
578
|
+
|
|
579
|
+
Recap builders index `weeks[idx]` directly; supplying one zero-filled
|
|
580
|
+
row keeps that access safe without leaking misleading numbers (the
|
|
581
|
+
rendered artifact shows $0.00 / 0.0% — accurate for an empty install).
|
|
582
|
+
"""
|
|
583
|
+
return {
|
|
584
|
+
"start_date": _share_now_utc().strftime("%Y-%m-%d"),
|
|
585
|
+
"cost_usd": 0.0,
|
|
586
|
+
"pct_used": 0.0,
|
|
587
|
+
"dollar_per_pct": 0.0,
|
|
588
|
+
"top_projects": [],
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _build_weekly_share_panel_data(options: dict,
|
|
593
|
+
snap: "DataSnapshot | None") -> dict:
|
|
594
|
+
"""Weekly panel_data — last 8 subscription weeks + current-week index.
|
|
595
|
+
|
|
596
|
+
Reuses `DataSnapshot.weekly_periods` (WeeklyPeriodRow list), already
|
|
597
|
+
built by `_dashboard_build_weekly_periods` in the sync thread. Empty
|
|
598
|
+
snapshots emit a one-week stub so the Recap builder's `weeks[idx]`
|
|
599
|
+
access stays safe (renders as $0.00 / 0.0% — accurate "no data").
|
|
600
|
+
"""
|
|
601
|
+
rows = list(getattr(snap, "weekly_periods", None) or []) if snap else []
|
|
602
|
+
# weekly_periods is newest-first (see _dashboard_build_weekly_periods).
|
|
603
|
+
# Take the newest 8 and reverse to oldest→newest — the Recap template
|
|
604
|
+
# reads weeks[0] as the start anchor and weeks[-1] as the right-edge
|
|
605
|
+
# (current-week) anchor, and current_week_index addresses that order.
|
|
606
|
+
rows_8 = list(reversed(rows[:8]))
|
|
607
|
+
weeks: list[dict] = []
|
|
608
|
+
current_idx = 0
|
|
609
|
+
for i, r in enumerate(rows_8):
|
|
610
|
+
if getattr(r, "is_current", False):
|
|
611
|
+
current_idx = i
|
|
612
|
+
# WeeklyPeriodRow.week_start_at is an ISO datetime string; the
|
|
613
|
+
# Recap shape wants a YYYY-MM-DD date label. Slice the leading
|
|
614
|
+
# 10 chars (or fall back to parsing).
|
|
615
|
+
wsa = getattr(r, "week_start_at", "") or ""
|
|
616
|
+
start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
|
|
617
|
+
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
618
|
+
used_pct_raw = getattr(r, "used_pct", None)
|
|
619
|
+
used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
|
|
620
|
+
dpp = float(getattr(r, "dollar_per_pct", 0.0) or 0.0)
|
|
621
|
+
# Per-week top_projects: WeeklyPeriodRow doesn't carry a
|
|
622
|
+
# per-project rollup, but `week_start_at` / `week_end_at` give us
|
|
623
|
+
# an exact range — aggregate session_entries once per week so the
|
|
624
|
+
# Recap template's `weeks[i].top_projects` table is meaningful.
|
|
625
|
+
# 8 queries per share render is the perf trade; cached.
|
|
626
|
+
week_end_at = getattr(r, "week_end_at", "") or ""
|
|
627
|
+
top_projects: list[tuple[str, float]] = []
|
|
628
|
+
try:
|
|
629
|
+
ws_dt = parse_iso_datetime(wsa, "week_start_at") if isinstance(wsa, str) and wsa else None
|
|
630
|
+
we_dt = parse_iso_datetime(week_end_at, "week_end_at") if isinstance(week_end_at, str) and week_end_at else None
|
|
631
|
+
except ValueError:
|
|
632
|
+
ws_dt = we_dt = None
|
|
633
|
+
if ws_dt is not None and we_dt is not None:
|
|
634
|
+
top_projects = sys.modules["cctally"]._share_top_projects_for_range(ws_dt, we_dt)
|
|
635
|
+
# Per-week × per-model breakdown (issue #33 cross-tab Detail).
|
|
636
|
+
models_list = getattr(r, "models", None) or []
|
|
637
|
+
models = {
|
|
638
|
+
(m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
|
|
639
|
+
for m in models_list
|
|
640
|
+
}
|
|
641
|
+
weeks.append({
|
|
642
|
+
"start_date": start_date,
|
|
643
|
+
"cost_usd": cost,
|
|
644
|
+
"pct_used": used_pct,
|
|
645
|
+
"dollar_per_pct": dpp,
|
|
646
|
+
"top_projects": top_projects,
|
|
647
|
+
"models": models,
|
|
648
|
+
})
|
|
649
|
+
if not weeks:
|
|
650
|
+
weeks = [_share_empty_week_stub()]
|
|
651
|
+
return {"weeks": weeks, "current_week_index": current_idx}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _build_current_week_share_panel_data(options: dict,
|
|
655
|
+
snap: "DataSnapshot | None") -> dict:
|
|
656
|
+
"""Current-week panel_data — KPI strip + daily progression + top projects.
|
|
657
|
+
|
|
658
|
+
Synthesized from `DataSnapshot.current_week` + `daily_panel` (no 1:1
|
|
659
|
+
CLI counterpart, per spec §9.5). `daily_progression` clips the daily
|
|
660
|
+
panel to the current subscription week.
|
|
661
|
+
"""
|
|
662
|
+
cw = getattr(snap, "current_week", None) if snap else None
|
|
663
|
+
daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
|
|
664
|
+
if cw is None:
|
|
665
|
+
# Empty-shape fallback — Recap builder renders "no data" gracefully.
|
|
666
|
+
return {
|
|
667
|
+
"kpi_cost_usd": 0.0,
|
|
668
|
+
"kpi_pct_used": 0.0,
|
|
669
|
+
"kpi_dollar_per_pct": 0.0,
|
|
670
|
+
"kpi_days_remaining": 0.0,
|
|
671
|
+
"daily_progression": [],
|
|
672
|
+
"top_projects": [],
|
|
673
|
+
"week_start_date": _share_now_utc().strftime("%Y-%m-%d"),
|
|
674
|
+
"display_tz": options.get("display_tz", "Etc/UTC"),
|
|
675
|
+
}
|
|
676
|
+
week_start = getattr(cw, "week_start_at", None)
|
|
677
|
+
week_end = getattr(cw, "week_end_at", None)
|
|
678
|
+
week_start_date = (
|
|
679
|
+
week_start.strftime("%Y-%m-%d") if isinstance(week_start, dt.datetime)
|
|
680
|
+
else _share_now_utc().strftime("%Y-%m-%d")
|
|
681
|
+
)
|
|
682
|
+
# Days remaining = hours_to_reset / 24
|
|
683
|
+
days_remaining = 0.0
|
|
684
|
+
if isinstance(week_end, dt.datetime):
|
|
685
|
+
remaining = (week_end - _share_now_utc()).total_seconds() / 86400.0
|
|
686
|
+
days_remaining = max(0.0, remaining)
|
|
687
|
+
used_pct = float(getattr(cw, "used_pct", 0.0) or 0.0) / 100.0
|
|
688
|
+
progression: list[dict] = []
|
|
689
|
+
if isinstance(week_start, dt.datetime):
|
|
690
|
+
ws_date = week_start.date()
|
|
691
|
+
# daily_panel is newest-first; iterate reversed so progression is
|
|
692
|
+
# oldest→newest, matching the Recap template's progression[-1] =
|
|
693
|
+
# today contract and the chart's left→right time axis.
|
|
694
|
+
for r in reversed(daily):
|
|
695
|
+
try:
|
|
696
|
+
d = dt.date.fromisoformat(getattr(r, "date", "") or "")
|
|
697
|
+
except ValueError:
|
|
698
|
+
continue
|
|
699
|
+
if d >= ws_date:
|
|
700
|
+
progression.append({
|
|
701
|
+
"date": d.isoformat(),
|
|
702
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
703
|
+
})
|
|
704
|
+
# Current-week top_projects: aggregate from `[week_start, now]`.
|
|
705
|
+
# `cw.week_end_at` is the reset instant; using `now` keeps the rollup
|
|
706
|
+
# symmetric with the panel's "spent this week" KPI (week-to-date).
|
|
707
|
+
top_projects: list[tuple[str, float]] = []
|
|
708
|
+
if isinstance(week_start, dt.datetime):
|
|
709
|
+
top_projects = sys.modules["cctally"]._share_top_projects_for_range(
|
|
710
|
+
week_start, _share_now_utc(),
|
|
711
|
+
)
|
|
712
|
+
return {
|
|
713
|
+
"kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
|
|
714
|
+
"kpi_pct_used": used_pct,
|
|
715
|
+
"kpi_dollar_per_pct": float(getattr(cw, "dollars_per_percent", 0.0) or 0.0),
|
|
716
|
+
"kpi_days_remaining": days_remaining,
|
|
717
|
+
"daily_progression": progression,
|
|
718
|
+
"top_projects": top_projects,
|
|
719
|
+
"week_start_date": week_start_date,
|
|
720
|
+
"display_tz": options.get("display_tz", "Etc/UTC"),
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _build_trend_share_panel_data(options: dict,
|
|
725
|
+
snap: "DataSnapshot | None") -> dict:
|
|
726
|
+
"""Trend panel_data — 8 weeks of $/% + 3-week delta KPI.
|
|
727
|
+
|
|
728
|
+
Reuses `DataSnapshot.trend` (TuiTrendRow list, already 8 rows).
|
|
729
|
+
"""
|
|
730
|
+
trend = list(getattr(snap, "trend", None) or []) if snap else []
|
|
731
|
+
weeks: list[dict] = []
|
|
732
|
+
for r in trend:
|
|
733
|
+
wsa = getattr(r, "week_start_at", None)
|
|
734
|
+
start_date = (
|
|
735
|
+
wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
|
|
736
|
+
else (str(wsa)[:10] if wsa else "")
|
|
737
|
+
)
|
|
738
|
+
used_pct_raw = getattr(r, "used_pct", None)
|
|
739
|
+
used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
|
|
740
|
+
dpp = float(getattr(r, "dollars_per_percent", 0.0) or 0.0)
|
|
741
|
+
weeks.append({
|
|
742
|
+
"start_date": start_date,
|
|
743
|
+
"cost_usd": dpp * (used_pct * 100.0), # ≈ row total
|
|
744
|
+
"pct_used": used_pct,
|
|
745
|
+
"dollar_per_pct": dpp,
|
|
746
|
+
})
|
|
747
|
+
# Compute 3-week delta: compare last row vs row-4-from-end.
|
|
748
|
+
delta = {"dpp_change_pct": 0.0, "cost_change_usd": 0.0}
|
|
749
|
+
if len(weeks) >= 4:
|
|
750
|
+
cur = weeks[-1]
|
|
751
|
+
ref = weeks[-4]
|
|
752
|
+
if ref["dollar_per_pct"]:
|
|
753
|
+
delta["dpp_change_pct"] = (
|
|
754
|
+
(cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
|
|
755
|
+
)
|
|
756
|
+
delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
|
|
757
|
+
return {"weeks": weeks, "delta_3_weeks": delta}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _build_daily_share_panel_data(options: dict,
|
|
761
|
+
snap: "DataSnapshot | None") -> dict:
|
|
762
|
+
"""Daily panel_data — last 7 days with top model per day + top projects.
|
|
763
|
+
|
|
764
|
+
Reuses `DataSnapshot.daily_panel` (DailyPanelRow list, 30 rows in
|
|
765
|
+
full); clips to the most recent 7 for the Recap.
|
|
766
|
+
"""
|
|
767
|
+
daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
|
|
768
|
+
# daily_panel is newest-first (today at index 0); take the most recent
|
|
769
|
+
# 7 and reverse to oldest→newest so the Recap template's days[-1]
|
|
770
|
+
# anchor lands on today.
|
|
771
|
+
last_7 = list(reversed(daily[:7]))
|
|
772
|
+
total = stable_sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
|
|
773
|
+
days: list[dict] = []
|
|
774
|
+
for r in last_7:
|
|
775
|
+
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
776
|
+
models = getattr(r, "models", None) or []
|
|
777
|
+
top_model = (models[0].get("model") if models else None) or "—"
|
|
778
|
+
days.append({
|
|
779
|
+
"date": getattr(r, "date", "") or "",
|
|
780
|
+
"cost_usd": cost,
|
|
781
|
+
"pct_of_period": cost / total,
|
|
782
|
+
"top_model": top_model,
|
|
783
|
+
})
|
|
784
|
+
# `days[*].date` is bucketed in display_tz by `_dashboard_build_daily_panel`,
|
|
785
|
+
# so the query window must use display-tz midnights too — otherwise entries
|
|
786
|
+
# near midnight (up to ±UTC-offset hours) get queried under the wrong UTC
|
|
787
|
+
# day and either spill into Other or vanish from cross-tab cells while
|
|
788
|
+
# still counted in the row total.
|
|
789
|
+
display_tz_name = options.get("display_tz", "Etc/UTC")
|
|
790
|
+
try:
|
|
791
|
+
_range_tz = ZoneInfo(display_tz_name) if display_tz_name else dt.timezone.utc
|
|
792
|
+
except Exception:
|
|
793
|
+
_range_tz = dt.timezone.utc
|
|
794
|
+
# Daily top_projects: aggregate over the 7-day window. Derive the
|
|
795
|
+
# range from the dates rendered above so the rollup covers exactly
|
|
796
|
+
# what the panel shows (rather than re-deriving "7 days ago" from
|
|
797
|
+
# now and potentially clipping the oldest bucket).
|
|
798
|
+
top_projects: list[tuple[str, float]] = []
|
|
799
|
+
if days:
|
|
800
|
+
try:
|
|
801
|
+
first_date = dt.date.fromisoformat(days[0]["date"])
|
|
802
|
+
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
803
|
+
range_start = dt.datetime(
|
|
804
|
+
first_date.year, first_date.month, first_date.day,
|
|
805
|
+
tzinfo=_range_tz,
|
|
806
|
+
)
|
|
807
|
+
# Include the last day in full — end-exclusive boundary at
|
|
808
|
+
# the start of the next display-tz day.
|
|
809
|
+
range_end = dt.datetime(
|
|
810
|
+
last_date.year, last_date.month, last_date.day,
|
|
811
|
+
tzinfo=_range_tz,
|
|
812
|
+
) + dt.timedelta(days=1)
|
|
813
|
+
top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
|
|
814
|
+
except (ValueError, KeyError):
|
|
815
|
+
top_projects = []
|
|
816
|
+
# Per-day × per-project breakdown (issue #33 cross-tab Detail).
|
|
817
|
+
per_day_per_project: dict[str, dict[str, float]] = {}
|
|
818
|
+
if days:
|
|
819
|
+
try:
|
|
820
|
+
first_date = dt.date.fromisoformat(days[0]["date"])
|
|
821
|
+
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
822
|
+
pdpp_range_start = dt.datetime(
|
|
823
|
+
first_date.year, first_date.month, first_date.day,
|
|
824
|
+
tzinfo=_range_tz,
|
|
825
|
+
)
|
|
826
|
+
pdpp_range_end = dt.datetime(
|
|
827
|
+
last_date.year, last_date.month, last_date.day,
|
|
828
|
+
tzinfo=_range_tz,
|
|
829
|
+
) + dt.timedelta(days=1)
|
|
830
|
+
per_day_per_project = sys.modules["cctally"]._share_per_day_per_project_for_range(
|
|
831
|
+
pdpp_range_start, pdpp_range_end,
|
|
832
|
+
display_tz=display_tz_name,
|
|
833
|
+
)
|
|
834
|
+
except (ValueError, KeyError):
|
|
835
|
+
per_day_per_project = {}
|
|
836
|
+
for d in days:
|
|
837
|
+
d["projects"] = per_day_per_project.get(d["date"], {})
|
|
838
|
+
return {"days": days, "top_projects": top_projects}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _build_monthly_share_panel_data(options: dict,
|
|
842
|
+
snap: "DataSnapshot | None") -> dict:
|
|
843
|
+
"""Monthly panel_data — last 12 months + top projects.
|
|
844
|
+
|
|
845
|
+
Reuses `DataSnapshot.monthly_periods` (MonthlyPeriodRow list).
|
|
846
|
+
`used_pct` isn't stored on MonthlyPeriodRow (monthly aggregates
|
|
847
|
+
don't carry a subscription-quota %), so it surfaces as 0.0.
|
|
848
|
+
"""
|
|
849
|
+
rows = list(getattr(snap, "monthly_periods", None) or []) if snap else []
|
|
850
|
+
# monthly_periods is newest-first (see _dashboard_build_monthly_periods).
|
|
851
|
+
# Reverse to oldest→newest — the Recap template reads months[0] as the
|
|
852
|
+
# period-start anchor and months[-1] as the most recent month.
|
|
853
|
+
rows = list(reversed(rows))
|
|
854
|
+
months: list[dict] = []
|
|
855
|
+
for r in rows:
|
|
856
|
+
models_list = getattr(r, "models", None) or []
|
|
857
|
+
top_model = (models_list[0].get("model") if models_list else None) or "—"
|
|
858
|
+
# Per-month × per-model breakdown (issue #33 cross-tab Detail).
|
|
859
|
+
models = {
|
|
860
|
+
(m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
|
|
861
|
+
for m in models_list
|
|
862
|
+
}
|
|
863
|
+
months.append({
|
|
864
|
+
"month": getattr(r, "label", "") or "", # "YYYY-MM"
|
|
865
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
866
|
+
"pct_used": 0.0,
|
|
867
|
+
"top_model": top_model,
|
|
868
|
+
"models": models,
|
|
869
|
+
})
|
|
870
|
+
# Monthly top_projects: aggregate across the entire 12-month window.
|
|
871
|
+
# Range = [first day of oldest month, last day of newest month + 1].
|
|
872
|
+
top_projects: list[tuple[str, float]] = []
|
|
873
|
+
if months:
|
|
874
|
+
try:
|
|
875
|
+
oldest_year, oldest_month = months[0]["month"].split("-")
|
|
876
|
+
newest_year, newest_month = months[-1]["month"].split("-")
|
|
877
|
+
range_start = dt.datetime(
|
|
878
|
+
int(oldest_year), int(oldest_month), 1,
|
|
879
|
+
tzinfo=dt.timezone.utc,
|
|
880
|
+
)
|
|
881
|
+
# End-exclusive: first day of the month AFTER the newest one.
|
|
882
|
+
ny, nm = int(newest_year), int(newest_month) + 1
|
|
883
|
+
if nm == 13:
|
|
884
|
+
ny += 1
|
|
885
|
+
nm = 1
|
|
886
|
+
range_end = dt.datetime(ny, nm, 1, tzinfo=dt.timezone.utc)
|
|
887
|
+
top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
|
|
888
|
+
except (ValueError, KeyError):
|
|
889
|
+
top_projects = []
|
|
890
|
+
return {"months": months, "top_projects": top_projects}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _build_forecast_share_panel_data(options: dict,
|
|
894
|
+
snap: "DataSnapshot | None") -> dict:
|
|
895
|
+
"""Forecast panel_data — projection + per-day budgets + days-to-ceiling.
|
|
896
|
+
|
|
897
|
+
Reuses ``DataSnapshot.forecast`` (ForecastOutput) and, when populated
|
|
898
|
+
by the sync thread, ``DataSnapshot.forecast_view`` (the kernel
|
|
899
|
+
wrapper from issue #57) for the (100, 90) budget pair.
|
|
900
|
+
``projection_curve`` is synthesized from ``r_avg`` / ``r_recent`` /
|
|
901
|
+
``inputs.p_now`` — the same arithmetic ``snapshot_to_envelope`` does
|
|
902
|
+
for ``week_avg_projection_pct`` / ``recent_24h_projection_pct``,
|
|
903
|
+
extended across the next 7 days.
|
|
904
|
+
"""
|
|
905
|
+
fc = getattr(snap, "forecast", None) if snap else None
|
|
906
|
+
fc_view = getattr(snap, "forecast_view", None) if snap else None
|
|
907
|
+
if fc is None:
|
|
908
|
+
return {
|
|
909
|
+
"projected_end_pct": 0.0,
|
|
910
|
+
"days_to_100pct": 0.0,
|
|
911
|
+
"days_to_90pct": 0.0,
|
|
912
|
+
"daily_budgets": {
|
|
913
|
+
"avg": 0.0, "recent_24h": 0.0,
|
|
914
|
+
"until_90pct": 0.0, "until_100pct": 0.0,
|
|
915
|
+
},
|
|
916
|
+
"projection_curve": [],
|
|
917
|
+
"confidence": "LOW CONF",
|
|
918
|
+
}
|
|
919
|
+
inputs = getattr(fc, "inputs", None)
|
|
920
|
+
p_now = float(getattr(inputs, "p_now", 0.0) or 0.0) if inputs else 0.0
|
|
921
|
+
remaining_hours = float(
|
|
922
|
+
getattr(inputs, "remaining_hours", 0.0) or 0.0
|
|
923
|
+
) if inputs else 0.0
|
|
924
|
+
confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
|
|
925
|
+
r_avg = float(getattr(fc, "r_avg", 0.0) or 0.0)
|
|
926
|
+
r_recent_raw = getattr(fc, "r_recent", None)
|
|
927
|
+
r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
|
|
928
|
+
# End-of-week projected %
|
|
929
|
+
projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
|
|
930
|
+
# Days to ceilings (simple inverse: hours-to-target / 24)
|
|
931
|
+
def _days_to_ceiling(target_pct: float) -> float:
|
|
932
|
+
if r_avg <= 0 or p_now >= target_pct:
|
|
933
|
+
return 0.0
|
|
934
|
+
hours = (target_pct - p_now) / r_avg
|
|
935
|
+
return max(0.0, hours / 24.0)
|
|
936
|
+
days_to_100 = _days_to_ceiling(100.0)
|
|
937
|
+
days_to_90 = _days_to_ceiling(90.0)
|
|
938
|
+
# Daily budgets — prefer ForecastView's pre-routed pair (issue #57)
|
|
939
|
+
# when available; otherwise replay the legacy ``fc.budgets`` scan
|
|
940
|
+
# inline so positionally-constructed fixture snapshots still work.
|
|
941
|
+
budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
|
|
942
|
+
"until_90pct": 0.0, "until_100pct": 0.0}
|
|
943
|
+
if fc_view is not None:
|
|
944
|
+
budgets["until_100pct"] = float(
|
|
945
|
+
fc_view.budget_100_per_day_usd or 0.0,
|
|
946
|
+
)
|
|
947
|
+
budgets["until_90pct"] = float(
|
|
948
|
+
fc_view.budget_90_per_day_usd or 0.0,
|
|
949
|
+
)
|
|
950
|
+
else:
|
|
951
|
+
for b in getattr(fc, "budgets", None) or []:
|
|
952
|
+
tp = getattr(b, "target_percent", None)
|
|
953
|
+
dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
|
|
954
|
+
if tp == 100:
|
|
955
|
+
budgets["until_100pct"] = dpd
|
|
956
|
+
elif tp == 90:
|
|
957
|
+
budgets["until_90pct"] = dpd
|
|
958
|
+
# avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
|
|
959
|
+
dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
|
|
960
|
+
budgets["avg"] = dpp * r_avg * 24.0
|
|
961
|
+
budgets["recent_24h"] = dpp * r_recent * 24.0
|
|
962
|
+
# Projection curve — 7-day forward, using r_avg
|
|
963
|
+
today = _share_now_utc().date()
|
|
964
|
+
projection_curve: list[dict] = []
|
|
965
|
+
for i in range(7):
|
|
966
|
+
d = today + dt.timedelta(days=i)
|
|
967
|
+
pct = (p_now + r_avg * (i * 24.0)) / 100.0
|
|
968
|
+
projection_curve.append({
|
|
969
|
+
"date": d.isoformat(),
|
|
970
|
+
"projected_pct_used": pct,
|
|
971
|
+
})
|
|
972
|
+
return {
|
|
973
|
+
"projected_end_pct": projected_end_pct,
|
|
974
|
+
"days_to_100pct": days_to_100,
|
|
975
|
+
"days_to_90pct": days_to_90,
|
|
976
|
+
"daily_budgets": budgets,
|
|
977
|
+
"projection_curve": projection_curve,
|
|
978
|
+
"confidence": confidence,
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _build_blocks_share_panel_data(options: dict,
|
|
983
|
+
snap: "DataSnapshot | None") -> dict:
|
|
984
|
+
"""Blocks panel_data — current 5h block KPI + 8 recent blocks + top projects.
|
|
985
|
+
|
|
986
|
+
Reuses `DataSnapshot.blocks_panel` (BlocksPanelRow list). Current
|
|
987
|
+
block is the row with `is_active=True`; recent_blocks are the last 8.
|
|
988
|
+
"""
|
|
989
|
+
rows = list(getattr(snap, "blocks_panel", None) or []) if snap else []
|
|
990
|
+
current = next((r for r in rows if getattr(r, "is_active", False)), None)
|
|
991
|
+
cb: dict = {}
|
|
992
|
+
if current is not None:
|
|
993
|
+
cb = {
|
|
994
|
+
"start_at": _share_iso(getattr(current, "start_at", None)) or "",
|
|
995
|
+
"end_at": _share_iso(getattr(current, "end_at", None)) or "",
|
|
996
|
+
"cost_usd": float(getattr(current, "cost_usd", 0.0) or 0.0),
|
|
997
|
+
"pct_used": 0.0, # BlocksPanelRow doesn't carry a %
|
|
998
|
+
"tokens_total": 0, # BlocksPanelRow drops token counts
|
|
999
|
+
}
|
|
1000
|
+
# blocks_panel is newest-first (see _dashboard_build_blocks_panel:
|
|
1001
|
+
# `rows.sort(key=lambda r: r.start_at, reverse=True)`). Take the most
|
|
1002
|
+
# recent 8 blocks and reverse to oldest→newest so the template's chart
|
|
1003
|
+
# (uses enumerate(recent) for x-position) plots left→right time order.
|
|
1004
|
+
recent: list[dict] = []
|
|
1005
|
+
for r in list(reversed(rows[:8])):
|
|
1006
|
+
recent.append({
|
|
1007
|
+
"start_at": _share_iso(getattr(r, "start_at", None)) or "",
|
|
1008
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
1009
|
+
})
|
|
1010
|
+
# Blocks top_projects: aggregate across the window covered by
|
|
1011
|
+
# `recent_blocks` (the oldest block's start through the most recent
|
|
1012
|
+
# block's end — also the active block, if any). Mirrors what the
|
|
1013
|
+
# panel actually shows the user.
|
|
1014
|
+
top_projects: list[tuple[str, float]] = []
|
|
1015
|
+
if recent:
|
|
1016
|
+
try:
|
|
1017
|
+
range_start = parse_iso_datetime(
|
|
1018
|
+
recent[0]["start_at"], "blocks.recent_blocks[0].start_at",
|
|
1019
|
+
)
|
|
1020
|
+
# Pick the end of the latest block. `recent` is oldest→newest
|
|
1021
|
+
# after the slice/reverse, so `recent[-1]` is the most recent.
|
|
1022
|
+
# Each block is 5 hours long; if `current_block` has an
|
|
1023
|
+
# explicit `end_at`, prefer that since it may be the active
|
|
1024
|
+
# block whose end_at lives in the future.
|
|
1025
|
+
if cb.get("end_at"):
|
|
1026
|
+
range_end = parse_iso_datetime(
|
|
1027
|
+
cb["end_at"], "blocks.current_block.end_at",
|
|
1028
|
+
)
|
|
1029
|
+
else:
|
|
1030
|
+
range_end = parse_iso_datetime(
|
|
1031
|
+
recent[-1]["start_at"], "blocks.recent_blocks[-1].start_at",
|
|
1032
|
+
) + dt.timedelta(hours=5)
|
|
1033
|
+
top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
|
|
1034
|
+
except (ValueError, KeyError):
|
|
1035
|
+
top_projects = []
|
|
1036
|
+
# Per-block × per-project breakdown (issue #33 cross-tab Detail).
|
|
1037
|
+
per_block_per_project = sys.modules["cctally"]._share_per_block_per_project(recent)
|
|
1038
|
+
for r in recent:
|
|
1039
|
+
r["projects"] = per_block_per_project.get(r["start_at"], {})
|
|
1040
|
+
return {
|
|
1041
|
+
"current_block": cb,
|
|
1042
|
+
"recent_blocks": recent,
|
|
1043
|
+
"top_projects": top_projects,
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _build_sessions_share_panel_data(options: dict,
|
|
1048
|
+
snap: "DataSnapshot | None") -> dict:
|
|
1049
|
+
"""Sessions panel_data — top N sessions table.
|
|
1050
|
+
|
|
1051
|
+
Reuses `DataSnapshot.sessions` (TuiSessionRow list). Truncated to
|
|
1052
|
+
`options.top_n` (default 15) by upstream cap before the Recap builder
|
|
1053
|
+
runs its own slice.
|
|
1054
|
+
"""
|
|
1055
|
+
rows = list(getattr(snap, "sessions", None) or []) if snap else []
|
|
1056
|
+
top_n = options.get("top_n", 15)
|
|
1057
|
+
try:
|
|
1058
|
+
top_n_int = max(1, int(top_n))
|
|
1059
|
+
except (TypeError, ValueError):
|
|
1060
|
+
top_n_int = 15
|
|
1061
|
+
sessions: list[dict] = []
|
|
1062
|
+
for r in rows[:top_n_int]:
|
|
1063
|
+
sessions.append({
|
|
1064
|
+
"session_id": getattr(r, "session_id", "") or "",
|
|
1065
|
+
"project_path": getattr(r, "project_label", "") or "",
|
|
1066
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
1067
|
+
"started_at": _share_iso(getattr(r, "started_at", None)) or "",
|
|
1068
|
+
"model": getattr(r, "model_primary", "") or "",
|
|
1069
|
+
})
|
|
1070
|
+
return {"sessions": sessions}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _build_projects_share_panel_data(options: dict,
|
|
1074
|
+
snap: "DataSnapshot | None") -> dict:
|
|
1075
|
+
"""Projects panel_data — per-project rollup over a selectable window.
|
|
1076
|
+
|
|
1077
|
+
Reuses ``DataSnapshot.projects_envelope`` already populated by the
|
|
1078
|
+
sync thread, so the share artifact matches what the dashboard panel
|
|
1079
|
+
is showing. ``options.windowWeeks`` (spec §5.4 + §7.3) selects the
|
|
1080
|
+
aggregation window:
|
|
1081
|
+
|
|
1082
|
+
- ``windowWeeks=1`` (default): current_week only (PANEL share flow).
|
|
1083
|
+
- ``windowWeeks ∈ {4, 8, 12}``: sum across the trend window
|
|
1084
|
+
(MODAL share flow — supplies its active window pill).
|
|
1085
|
+
|
|
1086
|
+
Output shape (consumed by `_build_projects_recap` / `_visual` /
|
|
1087
|
+
`_detail` builders below — see bin/_lib_share_templates.py):
|
|
1088
|
+
|
|
1089
|
+
{
|
|
1090
|
+
"rows": [
|
|
1091
|
+
{
|
|
1092
|
+
"key": "<disambiguated display_key>",
|
|
1093
|
+
"bucket_path": "<absolute path>",
|
|
1094
|
+
"cost_usd": <float>,
|
|
1095
|
+
"attributed_pct": <float | None>,
|
|
1096
|
+
"sessions_count": <int>,
|
|
1097
|
+
},
|
|
1098
|
+
... # desc by cost
|
|
1099
|
+
],
|
|
1100
|
+
"total_cost_usd": <float>,
|
|
1101
|
+
"period_start": <dt.datetime UTC>,
|
|
1102
|
+
"period_end": <dt.datetime UTC>,
|
|
1103
|
+
"window_weeks": <int>,
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
The Privacy invariant per spec §7.4 lives at the share-render gate
|
|
1107
|
+
(`_lib_share._scrub`), NOT here. This panel_data carries REAL
|
|
1108
|
+
display_keys + bucket_paths; downstream `_scrub` rewrites them
|
|
1109
|
+
when ``reveal_projects=false``.
|
|
1110
|
+
"""
|
|
1111
|
+
env: dict = getattr(snap, "projects_envelope", None) or {} if snap else {}
|
|
1112
|
+
if not env:
|
|
1113
|
+
# First-tick / sub-build failure → render a minimal "no data"
|
|
1114
|
+
# shape. _build_project_snapshot already handles empty rows
|
|
1115
|
+
# downstream via "no data" title.
|
|
1116
|
+
now = _share_now_utc()
|
|
1117
|
+
return {
|
|
1118
|
+
"rows": [],
|
|
1119
|
+
"total_cost_usd": 0.0,
|
|
1120
|
+
"period_start": now - dt.timedelta(days=7),
|
|
1121
|
+
"period_end": now,
|
|
1122
|
+
"window_weeks": 1,
|
|
1123
|
+
}
|
|
1124
|
+
weeks_back_raw = options.get("windowWeeks", 1)
|
|
1125
|
+
try:
|
|
1126
|
+
weeks_back = int(weeks_back_raw)
|
|
1127
|
+
except (TypeError, ValueError):
|
|
1128
|
+
weeks_back = 1
|
|
1129
|
+
if weeks_back not in {1, 4, 8, 12}:
|
|
1130
|
+
weeks_back = 1
|
|
1131
|
+
cw = env.get("current_week", {}) or {}
|
|
1132
|
+
trend = env.get("trend", {}) or {}
|
|
1133
|
+
|
|
1134
|
+
# `effective_weeks` is the actual number of weeks of data the artifact
|
|
1135
|
+
# represents. For the 1-week (panel) path it's always 1. For multi-week
|
|
1136
|
+
# (modal) the trend envelope may carry fewer weeks than requested on
|
|
1137
|
+
# thin-history dashboards (fresh installs, post-rebuild), so clamp to
|
|
1138
|
+
# whatever history exists — otherwise the share artifact would label
|
|
1139
|
+
# itself "Last 12 weeks" and render a 12-week date range while only
|
|
1140
|
+
# (say) 3 weeks of rows were aggregated. The period bounds and the
|
|
1141
|
+
# `window_weeks` returned downstream both ride on `effective_weeks`.
|
|
1142
|
+
rows: list[dict]
|
|
1143
|
+
if weeks_back == 1:
|
|
1144
|
+
effective_weeks = 1
|
|
1145
|
+
rows = [
|
|
1146
|
+
{
|
|
1147
|
+
"key": r["key"],
|
|
1148
|
+
"bucket_path": r["bucket_path"],
|
|
1149
|
+
"cost_usd": float(r["cost_usd"]),
|
|
1150
|
+
"attributed_pct": r.get("attributed_pct"),
|
|
1151
|
+
"sessions_count": int(r.get("sessions_count", 0) or 0),
|
|
1152
|
+
}
|
|
1153
|
+
for r in (cw.get("rows") or [])
|
|
1154
|
+
]
|
|
1155
|
+
total_cost = float(cw.get("total_cost_usd", 0.0) or 0.0)
|
|
1156
|
+
else:
|
|
1157
|
+
# Multi-week: sum across the trailing `weeks_back` slices of
|
|
1158
|
+
# trend.projects[i].weekly_cost. attributed_pct sums each
|
|
1159
|
+
# project's weekly_pct (None when no week has a snapshot).
|
|
1160
|
+
n_weeks = len(trend.get("weeks") or [])
|
|
1161
|
+
# The trend window is already clamped to <= 12; we take the
|
|
1162
|
+
# trailing `weeks_back` slices.
|
|
1163
|
+
take = min(weeks_back, n_weeks)
|
|
1164
|
+
# On a brand-new dashboard with zero trend weeks, fall back to a
|
|
1165
|
+
# single-week (current_week) period so the artifact's labelling
|
|
1166
|
+
# still names a real range instead of "Last 0 weeks".
|
|
1167
|
+
effective_weeks = max(1, take)
|
|
1168
|
+
rows = []
|
|
1169
|
+
running_total = 0.0
|
|
1170
|
+
for tp in trend.get("projects") or []:
|
|
1171
|
+
wc = (tp.get("weekly_cost") or [])[-take:]
|
|
1172
|
+
wp = (tp.get("weekly_pct") or [])[-take:]
|
|
1173
|
+
ws = (tp.get("sessions_per_week") or [])[-take:]
|
|
1174
|
+
cost = float(stable_sum(wc))
|
|
1175
|
+
running_total += cost
|
|
1176
|
+
valid_pct = [float(p) for p in wp if p is not None]
|
|
1177
|
+
attributed = stable_sum(valid_pct) if valid_pct else None
|
|
1178
|
+
# Sum per-week distinct session counts. Slight over-count when a
|
|
1179
|
+
# single session spans a week boundary; the envelope's per-week
|
|
1180
|
+
# bucketing has no session-id sets to union, so this is the
|
|
1181
|
+
# cheapest reasonable approximation and matches the modal's
|
|
1182
|
+
# client-side derivation (envelope.ts → ProjectsModal.tsx).
|
|
1183
|
+
rows.append({
|
|
1184
|
+
"key": tp["key"],
|
|
1185
|
+
"bucket_path": tp["bucket_path"],
|
|
1186
|
+
"cost_usd": cost,
|
|
1187
|
+
"attributed_pct": attributed,
|
|
1188
|
+
# Integer session counts — bare sum() is exact (NOT a
|
|
1189
|
+
# stable_sum float-output site; see test_stable_sum_chokepoint).
|
|
1190
|
+
"sessions_count": int(sum(ws)),
|
|
1191
|
+
})
|
|
1192
|
+
rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
|
|
1193
|
+
total_cost = running_total
|
|
1194
|
+
|
|
1195
|
+
# Compute window bounds from the *effective* span — see the
|
|
1196
|
+
# `effective_weeks` note above. The rows in this panel_data are
|
|
1197
|
+
# week-to-date (current_week.rows are aggregated through "now"; the
|
|
1198
|
+
# multi-week branch sums weekly_cost slices, with the trailing slice
|
|
1199
|
+
# also week-to-date), so clip `period_end` to min(reset_at, now).
|
|
1200
|
+
# Without the clip a mid-week export advertises a future reset date
|
|
1201
|
+
# in the rendered period/frontmatter and disagrees with the live
|
|
1202
|
+
# dashboard's "spent this week" KPI, which is symmetrically clipped
|
|
1203
|
+
# by `_build_current_week_share_panel_data`'s use of `now`.
|
|
1204
|
+
cw_start_iso = cw.get("week_start_at") or _share_now_utc_iso()
|
|
1205
|
+
cw_start = parse_iso_datetime(cw_start_iso, "projects.cw_start")
|
|
1206
|
+
week_end = cw_start + dt.timedelta(days=7)
|
|
1207
|
+
now = _share_now_utc()
|
|
1208
|
+
period_end = week_end if week_end <= now else now
|
|
1209
|
+
period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
"rows": rows,
|
|
1213
|
+
"total_cost_usd": total_cost,
|
|
1214
|
+
"period_start": period_start,
|
|
1215
|
+
"period_end": period_end,
|
|
1216
|
+
"window_weeks": effective_weeks,
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
# ---- share endpoints (spec §5.1) ----------------------------------
|
|
1221
|
+
#
|
|
1222
|
+
# GET /api/share/templates?panel=<id> → list Recap/Visual/Detail
|
|
1223
|
+
# templates registered in _lib_share_templates for that panel.
|
|
1224
|
+
# POST /api/share/render → render one panel-section to
|
|
1225
|
+
# body via the kernel; returns {body, content_type, snapshot}
|
|
1226
|
+
# with kernel_version + data_digest for v2 composer drift checks.
|
|
1227
|
+
#
|
|
1228
|
+
# The template registry is late-imported per-request to keep dashboard
|
|
1229
|
+
# startup cheap — matches cmd_tui's `rich` lazy-import pattern. Same
|
|
1230
|
+
# late-load applies to the kernel (`_lib_share`) via `_share_load_lib`.
|
|
1231
|
+
# GET is unauthenticated (idempotent read). POST gates on
|
|
1232
|
+
# `_check_origin_csrf` (same convention as /api/sync, /api/settings).
|
|
1233
|
+
|
|
1234
|
+
def _share_load_templates_module_impl(handler):
|
|
1235
|
+
"""Late-load the share-templates registry, cached in sys.modules.
|
|
1236
|
+
|
|
1237
|
+
Keeps dashboard startup zero-cost — the registry only imports when
|
|
1238
|
+
the first share request arrives. Subsequent requests reuse the
|
|
1239
|
+
sys.modules entry; matches the `_share_load_lib` convention so
|
|
1240
|
+
ShareTemplate identity stays stable across calls.
|
|
1241
|
+
"""
|
|
1242
|
+
cached = sys.modules.get("_lib_share_templates")
|
|
1243
|
+
if cached is not None:
|
|
1244
|
+
return cached
|
|
1245
|
+
import importlib.util as _ilu
|
|
1246
|
+
p = pathlib.Path(__file__).resolve().parent / "_lib_share_templates.py"
|
|
1247
|
+
spec = _ilu.spec_from_file_location("_lib_share_templates", p)
|
|
1248
|
+
mod = _ilu.module_from_spec(spec)
|
|
1249
|
+
sys.modules["_lib_share_templates"] = mod
|
|
1250
|
+
try:
|
|
1251
|
+
spec.loader.exec_module(mod)
|
|
1252
|
+
except Exception:
|
|
1253
|
+
sys.modules.pop("_lib_share_templates", None)
|
|
1254
|
+
raise
|
|
1255
|
+
return mod
|
|
1256
|
+
|
|
1257
|
+
def _handle_share_templates_get_impl(handler) -> None:
|
|
1258
|
+
"""List share templates registered for the requested panel.
|
|
1259
|
+
|
|
1260
|
+
Query: ?panel=<id>. Rejects missing or non-share-capable panels
|
|
1261
|
+
(e.g., `alerts`) with 400 + {error, field} envelope (matches
|
|
1262
|
+
existing dashboard error shape; see spec §5.5).
|
|
1263
|
+
"""
|
|
1264
|
+
import urllib.parse as _urlparse
|
|
1265
|
+
qs = _urlparse.urlparse(handler.path).query
|
|
1266
|
+
params = _urlparse.parse_qs(qs)
|
|
1267
|
+
panel = (params.get("panel", [""])[0] or "").strip()
|
|
1268
|
+
if not panel:
|
|
1269
|
+
handler._respond_json(400, {
|
|
1270
|
+
"error": "missing query param: panel",
|
|
1271
|
+
"field": "panel",
|
|
1272
|
+
})
|
|
1273
|
+
return
|
|
1274
|
+
tpl_mod = handler._share_load_templates_module()
|
|
1275
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
1276
|
+
handler._respond_json(400, {
|
|
1277
|
+
"error": f"unknown share panel: {panel!r}",
|
|
1278
|
+
"field": "panel",
|
|
1279
|
+
})
|
|
1280
|
+
return
|
|
1281
|
+
templates = [
|
|
1282
|
+
{
|
|
1283
|
+
"id": t.id,
|
|
1284
|
+
"label": t.label,
|
|
1285
|
+
"description": t.description,
|
|
1286
|
+
"default_options": dict(t.default_options),
|
|
1287
|
+
}
|
|
1288
|
+
for t in tpl_mod.templates_for_panel(panel)
|
|
1289
|
+
]
|
|
1290
|
+
handler._respond_json(200, {"panel": panel, "templates": templates})
|
|
1291
|
+
|
|
1292
|
+
def _handle_share_render_post_impl(handler) -> None:
|
|
1293
|
+
"""Render a panel-section to body via the share kernel.
|
|
1294
|
+
|
|
1295
|
+
Body shape: ``{panel, template_id, options}``. Validates panel +
|
|
1296
|
+
template_id against the registry, dispatches to the per-panel
|
|
1297
|
+
`_build_<panel>_share_panel_data` helper to assemble the
|
|
1298
|
+
builder-shaped dict from the current dashboard snapshot, runs the
|
|
1299
|
+
template's builder, applies `_scrub` when
|
|
1300
|
+
``options.reveal_projects`` is False, then renders via
|
|
1301
|
+
`_lib_share.render`. Response: ``{body, content_type, snapshot}``
|
|
1302
|
+
where `snapshot` carries `kernel_version` + `data_digest` for the
|
|
1303
|
+
v2 composer's drift detection (spec §5.2).
|
|
1304
|
+
|
|
1305
|
+
CSRF: Origin/Host parity via `_check_origin_csrf` — same gate as
|
|
1306
|
+
`/api/sync`, `/api/settings`, `/api/alerts/test`.
|
|
1307
|
+
"""
|
|
1308
|
+
if not handler._check_origin_csrf():
|
|
1309
|
+
return
|
|
1310
|
+
try:
|
|
1311
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
1312
|
+
except ValueError:
|
|
1313
|
+
length = 0
|
|
1314
|
+
if length > _SHARE_POST_MAX_BYTES:
|
|
1315
|
+
# #279 S1 F3: bound the body before reading it (memory/slow-loris).
|
|
1316
|
+
# length == 0 stays allowed below (empty body -> {}); cap only the top.
|
|
1317
|
+
handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
|
|
1318
|
+
return
|
|
1319
|
+
try:
|
|
1320
|
+
raw = handler.rfile.read(length) if length > 0 else b""
|
|
1321
|
+
req = json.loads(raw) if raw else {}
|
|
1322
|
+
except (ValueError, json.JSONDecodeError):
|
|
1323
|
+
handler._respond_json(400, {"error": "malformed json"})
|
|
1324
|
+
return
|
|
1325
|
+
if not isinstance(req, dict):
|
|
1326
|
+
handler._respond_json(400, {"error": "expected JSON object"})
|
|
1327
|
+
return
|
|
1328
|
+
panel = req.get("panel")
|
|
1329
|
+
template_id = req.get("template_id")
|
|
1330
|
+
options = req.get("options") or {}
|
|
1331
|
+
if not isinstance(options, dict):
|
|
1332
|
+
handler._respond_json(400, {
|
|
1333
|
+
"error": "options must be an object",
|
|
1334
|
+
"field": "options",
|
|
1335
|
+
})
|
|
1336
|
+
return
|
|
1337
|
+
# Client `ShareOptions` (dashboard/web/src/share/types.ts) does
|
|
1338
|
+
# not carry `display_tz`; server-side config is the source of
|
|
1339
|
+
# truth. Inject before `_share_apply_period_override` so the
|
|
1340
|
+
# daily panel rebuild and per-day cross-tab bucketing both see
|
|
1341
|
+
# the user's display tz instead of falling back to UTC.
|
|
1342
|
+
if "display_tz" not in options:
|
|
1343
|
+
options["display_tz"] = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
|
|
1344
|
+
if not isinstance(panel, str) or not panel:
|
|
1345
|
+
handler._respond_json(400, {
|
|
1346
|
+
"error": "missing or non-string panel",
|
|
1347
|
+
"field": "panel",
|
|
1348
|
+
})
|
|
1349
|
+
return
|
|
1350
|
+
if not isinstance(template_id, str) or not template_id:
|
|
1351
|
+
handler._respond_json(400, {
|
|
1352
|
+
"error": "missing or non-string template_id",
|
|
1353
|
+
"field": "template_id",
|
|
1354
|
+
})
|
|
1355
|
+
return
|
|
1356
|
+
fmt = options.get("format", "html")
|
|
1357
|
+
if fmt not in ("md", "html", "svg"):
|
|
1358
|
+
handler._respond_json(400, {
|
|
1359
|
+
"error": f"unknown format: {fmt!r}",
|
|
1360
|
+
"field": "options.format",
|
|
1361
|
+
})
|
|
1362
|
+
return
|
|
1363
|
+
theme = options.get("theme", "light")
|
|
1364
|
+
if theme not in ("light", "dark"):
|
|
1365
|
+
handler._respond_json(400, {
|
|
1366
|
+
"error": f"unknown theme: {theme!r}",
|
|
1367
|
+
"field": "options.theme",
|
|
1368
|
+
})
|
|
1369
|
+
return
|
|
1370
|
+
# `top_n` may be explicit-null when the UI's Top-N input is
|
|
1371
|
+
# cleared (Knobs.tsx:43); treat null as "use template default"
|
|
1372
|
+
# rather than 400-ing every preview/export until the user types
|
|
1373
|
+
# a number.
|
|
1374
|
+
if options.get("top_n") is not None:
|
|
1375
|
+
top_n_raw = options["top_n"]
|
|
1376
|
+
if not isinstance(top_n_raw, int) or isinstance(top_n_raw, bool) or top_n_raw < 1:
|
|
1377
|
+
handler._respond_json(400, {
|
|
1378
|
+
"error": f"top_n must be a positive integer, got {top_n_raw!r}",
|
|
1379
|
+
"field": "options.top_n",
|
|
1380
|
+
})
|
|
1381
|
+
return
|
|
1382
|
+
|
|
1383
|
+
tpl_mod = handler._share_load_templates_module()
|
|
1384
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
1385
|
+
handler._respond_json(400, {
|
|
1386
|
+
"error": f"unknown share panel: {panel!r}",
|
|
1387
|
+
"field": "panel",
|
|
1388
|
+
})
|
|
1389
|
+
return
|
|
1390
|
+
try:
|
|
1391
|
+
template = tpl_mod.get_template(template_id)
|
|
1392
|
+
except KeyError:
|
|
1393
|
+
handler._respond_json(400, {
|
|
1394
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
1395
|
+
"field": "template_id",
|
|
1396
|
+
})
|
|
1397
|
+
return
|
|
1398
|
+
if template.panel != panel:
|
|
1399
|
+
handler._respond_json(400, {
|
|
1400
|
+
"error": (
|
|
1401
|
+
f"template_id {template_id!r} belongs to panel "
|
|
1402
|
+
f"{template.panel!r}, not {panel!r}"
|
|
1403
|
+
),
|
|
1404
|
+
"field": "template_id",
|
|
1405
|
+
})
|
|
1406
|
+
return
|
|
1407
|
+
|
|
1408
|
+
# Build panel_data from the live dashboard snapshot — reuses the
|
|
1409
|
+
# already-built `DataSnapshot` so we don't re-query the DB on the
|
|
1410
|
+
# share hot path. `_build_share_panel_data` dispatches per panel.
|
|
1411
|
+
snap_ref = type(handler).snapshot_ref
|
|
1412
|
+
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
1413
|
+
# Period override (current / previous / custom). For
|
|
1414
|
+
# `kind='current'` (the default) this is a no-op; otherwise we
|
|
1415
|
+
# re-build the relevant panel's DataSnapshot field from DB with
|
|
1416
|
+
# a shifted `now_utc` before slicing.
|
|
1417
|
+
data_snap, period_err = _share_apply_period_override(panel, options,
|
|
1418
|
+
data_snap)
|
|
1419
|
+
if period_err is not None:
|
|
1420
|
+
handler._respond_json(400, period_err)
|
|
1421
|
+
return
|
|
1422
|
+
try:
|
|
1423
|
+
panel_data = _build_share_panel_data(panel, options, data_snap)
|
|
1424
|
+
except Exception as exc:
|
|
1425
|
+
handler._respond_json(500, {"error": f"panel_data build failed: {exc}"})
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
# Run template builder → kernel render. Builder produces a
|
|
1429
|
+
# ShareSnapshot; `_scrub` anonymizes project labels when the
|
|
1430
|
+
# client opted in to anon-on-export (`reveal_projects=False`).
|
|
1431
|
+
ls = _share_load_lib()
|
|
1432
|
+
try:
|
|
1433
|
+
snap_built = template.builder(panel_data=panel_data, options=options)
|
|
1434
|
+
except Exception as exc:
|
|
1435
|
+
handler._respond_json(500, {"error": f"builder failed: {exc}"})
|
|
1436
|
+
return
|
|
1437
|
+
snap_built = replace(snap_built, template_id=template_id)
|
|
1438
|
+
# Content toggles (spec §Q4). Defaults match the existing
|
|
1439
|
+
# behavior (chart on, table on); explicit False strips the
|
|
1440
|
+
# corresponding section from the ShareSnapshot. ShareSnapshot
|
|
1441
|
+
# is frozen so we use dataclasses.replace.
|
|
1442
|
+
snap_built = _share_apply_content_toggles(snap_built, options)
|
|
1443
|
+
reveal = bool(options.get("reveal_projects", True))
|
|
1444
|
+
if not reveal:
|
|
1445
|
+
snap_built = ls._scrub(snap_built, reveal_projects=False)
|
|
1446
|
+
try:
|
|
1447
|
+
body = ls.render(
|
|
1448
|
+
snap_built,
|
|
1449
|
+
format=fmt,
|
|
1450
|
+
theme=options.get("theme", "light"),
|
|
1451
|
+
branding=not options.get("no_branding", False),
|
|
1452
|
+
)
|
|
1453
|
+
except Exception as exc:
|
|
1454
|
+
handler._respond_json(500, {"error": f"render failed: {exc}"})
|
|
1455
|
+
return
|
|
1456
|
+
content_type = {
|
|
1457
|
+
"md": "text/markdown",
|
|
1458
|
+
"html": "text/html",
|
|
1459
|
+
"svg": "image/svg+xml",
|
|
1460
|
+
}[fmt]
|
|
1461
|
+
|
|
1462
|
+
# data_digest hashes the inputs that identify the underlying DATA
|
|
1463
|
+
# (panel + template + panel_data), NOT rendering toggles like theme
|
|
1464
|
+
# / branding / reveal_projects / format. Used by the composer to
|
|
1465
|
+
# detect "section data has drifted since add-time" (spec §5.2 /
|
|
1466
|
+
# §7.1) — flipping anon-on-export must not register as drift, since
|
|
1467
|
+
# the underlying data is identical.
|
|
1468
|
+
digest_input = {
|
|
1469
|
+
"panel": panel,
|
|
1470
|
+
"template_id": template_id,
|
|
1471
|
+
"panel_data": panel_data,
|
|
1472
|
+
}
|
|
1473
|
+
try:
|
|
1474
|
+
data_digest = ls._data_digest(digest_input)
|
|
1475
|
+
except Exception:
|
|
1476
|
+
# Defensive: digest is non-blocking for the response — fall
|
|
1477
|
+
# back to an empty string and let the composer treat it as
|
|
1478
|
+
# "always drifted" rather than failing the whole render.
|
|
1479
|
+
data_digest = ""
|
|
1480
|
+
|
|
1481
|
+
handler._respond_json(200, {
|
|
1482
|
+
"body": body,
|
|
1483
|
+
"content_type": content_type,
|
|
1484
|
+
"snapshot": {
|
|
1485
|
+
"kernel_version": ls.KERNEL_VERSION,
|
|
1486
|
+
"panel": panel,
|
|
1487
|
+
"template_id": template_id,
|
|
1488
|
+
"options": options,
|
|
1489
|
+
"generated_at": _share_now_utc_iso(),
|
|
1490
|
+
"data_digest": data_digest,
|
|
1491
|
+
},
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
# ---- /api/share/compose — stitch many basket sections (spec §5.3) ----
|
|
1495
|
+
|
|
1496
|
+
def _handle_share_compose_post_impl(handler) -> None:
|
|
1497
|
+
"""Stitch multiple panel sections into one composed document.
|
|
1498
|
+
|
|
1499
|
+
Recipe-only. The server re-renders every section from its
|
|
1500
|
+
``(panel, template_id, options)`` recipe — never accepting a client-
|
|
1501
|
+
supplied ``body``. Per-section drift detection compares the fresh
|
|
1502
|
+
``data_digest`` against the client's ``data_digest_at_add``;
|
|
1503
|
+
mismatches surface as ``section_results[i].drift_detected = true``
|
|
1504
|
+
for the composer's "Outdated" badge.
|
|
1505
|
+
|
|
1506
|
+
Spec §5.3, §10.3. CSRF-gated.
|
|
1507
|
+
"""
|
|
1508
|
+
if not handler._check_origin_csrf():
|
|
1509
|
+
return
|
|
1510
|
+
try:
|
|
1511
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
1512
|
+
except ValueError:
|
|
1513
|
+
length = 0
|
|
1514
|
+
if length > _SHARE_POST_MAX_BYTES:
|
|
1515
|
+
# #279 S1 F3: bound the body before reading it (memory/slow-loris).
|
|
1516
|
+
# length == 0 stays allowed below (empty body -> {}); cap only the top.
|
|
1517
|
+
handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
|
|
1518
|
+
return
|
|
1519
|
+
try:
|
|
1520
|
+
raw = handler.rfile.read(length) if length > 0 else b""
|
|
1521
|
+
req = json.loads(raw) if raw else {}
|
|
1522
|
+
except (ValueError, json.JSONDecodeError):
|
|
1523
|
+
handler._respond_json(400, {"error": "malformed json"})
|
|
1524
|
+
return
|
|
1525
|
+
if not isinstance(req, dict):
|
|
1526
|
+
handler._respond_json(400, {"error": "expected JSON object"})
|
|
1527
|
+
return
|
|
1528
|
+
|
|
1529
|
+
title = req.get("title")
|
|
1530
|
+
theme = req.get("theme", "light")
|
|
1531
|
+
fmt = req.get("format", "html")
|
|
1532
|
+
no_branding = bool(req.get("no_branding", False))
|
|
1533
|
+
reveal_projects = bool(req.get("reveal_projects", False))
|
|
1534
|
+
sections_in = req.get("sections")
|
|
1535
|
+
if not isinstance(title, str) or not title:
|
|
1536
|
+
handler._respond_json(400, {"error": "missing title", "field": "title"})
|
|
1537
|
+
return
|
|
1538
|
+
if theme not in ("light", "dark"):
|
|
1539
|
+
handler._respond_json(400, {"error": f"unknown theme: {theme!r}",
|
|
1540
|
+
"field": "theme"})
|
|
1541
|
+
return
|
|
1542
|
+
if fmt not in ("md", "html", "svg"):
|
|
1543
|
+
handler._respond_json(400, {"error": f"unknown format: {fmt!r}",
|
|
1544
|
+
"field": "format"})
|
|
1545
|
+
return
|
|
1546
|
+
if not isinstance(sections_in, list) or not sections_in:
|
|
1547
|
+
handler._respond_json(400, {
|
|
1548
|
+
"error": "sections must be a non-empty array",
|
|
1549
|
+
"field": "sections",
|
|
1550
|
+
})
|
|
1551
|
+
return
|
|
1552
|
+
|
|
1553
|
+
tpl_mod = handler._share_load_templates_module()
|
|
1554
|
+
ls = _share_load_lib()
|
|
1555
|
+
snap_ref = type(handler).snapshot_ref
|
|
1556
|
+
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
1557
|
+
# Resolve display_tz from config once (client `ShareOptions`
|
|
1558
|
+
# does not carry it); applied to every section's options below
|
|
1559
|
+
# so daily panel rebuilds and per-day cross-tab cells bucket in
|
|
1560
|
+
# the user's display tz, not UTC.
|
|
1561
|
+
composite_display_tz = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
|
|
1562
|
+
|
|
1563
|
+
composed_sections: list = []
|
|
1564
|
+
section_results: list[dict] = []
|
|
1565
|
+
|
|
1566
|
+
for idx, sec in enumerate(sections_in):
|
|
1567
|
+
if not isinstance(sec, dict):
|
|
1568
|
+
handler._respond_json(400, {
|
|
1569
|
+
"error": f"sections[{idx}] must be an object",
|
|
1570
|
+
"field": f"sections[{idx}]",
|
|
1571
|
+
})
|
|
1572
|
+
return
|
|
1573
|
+
# Explicit: client-supplied `body` and `content_type` are
|
|
1574
|
+
# silently IGNORED. This is the privacy chokepoint — the
|
|
1575
|
+
# regression test in tests/test_api_share.py guards it.
|
|
1576
|
+
snap_recipe = sec.get("snapshot") or {}
|
|
1577
|
+
panel = snap_recipe.get("panel")
|
|
1578
|
+
template_id = snap_recipe.get("template_id")
|
|
1579
|
+
sec_opts = snap_recipe.get("options") or {}
|
|
1580
|
+
digest_at_add = snap_recipe.get("data_digest_at_add") or ""
|
|
1581
|
+
if (not isinstance(panel, str)
|
|
1582
|
+
or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
|
|
1583
|
+
handler._respond_json(400, {
|
|
1584
|
+
"error": (
|
|
1585
|
+
f"sections[{idx}].snapshot.panel invalid: {panel!r}"
|
|
1586
|
+
),
|
|
1587
|
+
"field": f"sections[{idx}].snapshot.panel",
|
|
1588
|
+
})
|
|
1589
|
+
return
|
|
1590
|
+
try:
|
|
1591
|
+
template = tpl_mod.get_template(template_id)
|
|
1592
|
+
except KeyError:
|
|
1593
|
+
handler._respond_json(400, {
|
|
1594
|
+
"error": (
|
|
1595
|
+
f"sections[{idx}].snapshot.template_id "
|
|
1596
|
+
f"unknown: {template_id!r}"
|
|
1597
|
+
),
|
|
1598
|
+
"field": f"sections[{idx}].snapshot.template_id",
|
|
1599
|
+
})
|
|
1600
|
+
return
|
|
1601
|
+
if template.panel != panel:
|
|
1602
|
+
handler._respond_json(400, {
|
|
1603
|
+
"error": (f"sections[{idx}].snapshot.template_id "
|
|
1604
|
+
f"{template_id!r} belongs to panel "
|
|
1605
|
+
f"{template.panel!r}, not {panel!r}"),
|
|
1606
|
+
"field": f"sections[{idx}].snapshot.template_id",
|
|
1607
|
+
})
|
|
1608
|
+
return
|
|
1609
|
+
|
|
1610
|
+
# Force the composite reveal_projects across every section
|
|
1611
|
+
# (spec §8.5: per-section anon at add-time is ignored at compose).
|
|
1612
|
+
composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
|
|
1613
|
+
"theme": theme, "format": fmt,
|
|
1614
|
+
"no_branding": no_branding}
|
|
1615
|
+
composite_opts.setdefault("display_tz", composite_display_tz)
|
|
1616
|
+
# Per-section period override — each basket item carries its
|
|
1617
|
+
# own period recipe, independent of the composite anon flag.
|
|
1618
|
+
sec_snap, period_err = _share_apply_period_override(
|
|
1619
|
+
panel, composite_opts, data_snap,
|
|
1620
|
+
)
|
|
1621
|
+
if period_err is not None:
|
|
1622
|
+
handler._respond_json(400, {
|
|
1623
|
+
"error": f"sections[{idx}]: {period_err['error']}",
|
|
1624
|
+
"field": f"sections[{idx}].snapshot.{period_err['field']}",
|
|
1625
|
+
})
|
|
1626
|
+
return
|
|
1627
|
+
try:
|
|
1628
|
+
panel_data = _build_share_panel_data(panel, composite_opts,
|
|
1629
|
+
sec_snap)
|
|
1630
|
+
except Exception as exc:
|
|
1631
|
+
handler._respond_json(500, {
|
|
1632
|
+
"error": f"sections[{idx}] panel_data build failed: {exc}",
|
|
1633
|
+
})
|
|
1634
|
+
return
|
|
1635
|
+
try:
|
|
1636
|
+
snap_built = template.builder(panel_data=panel_data,
|
|
1637
|
+
options=composite_opts)
|
|
1638
|
+
except Exception as exc:
|
|
1639
|
+
handler._respond_json(500, {
|
|
1640
|
+
"error": f"sections[{idx}] builder failed: {exc}",
|
|
1641
|
+
})
|
|
1642
|
+
return
|
|
1643
|
+
snap_built = replace(snap_built, template_id=template_id)
|
|
1644
|
+
# Same content toggles as the single-section render path.
|
|
1645
|
+
# Per-section `show_chart`/`show_table` from the basket
|
|
1646
|
+
# recipe are applied here; the composite anon flag is
|
|
1647
|
+
# already merged into composite_opts upstream.
|
|
1648
|
+
snap_built = _share_apply_content_toggles(snap_built, composite_opts)
|
|
1649
|
+
if not reveal_projects:
|
|
1650
|
+
snap_built = ls._scrub(snap_built, reveal_projects=False)
|
|
1651
|
+
|
|
1652
|
+
# Defensive: digest is non-blocking metadata — fall back to
|
|
1653
|
+
# "" on failure rather than 500-ing the whole compose
|
|
1654
|
+
# (mirrors the render handler at bin/cctally:33402-33408).
|
|
1655
|
+
try:
|
|
1656
|
+
digest_now = ls._data_digest({
|
|
1657
|
+
"panel": panel,
|
|
1658
|
+
"template_id": template_id,
|
|
1659
|
+
"panel_data": panel_data,
|
|
1660
|
+
})
|
|
1661
|
+
except Exception:
|
|
1662
|
+
digest_now = ""
|
|
1663
|
+
composed_sections.append(ls.ComposedSection(
|
|
1664
|
+
snap=snap_built,
|
|
1665
|
+
drift_detected=(digest_now != digest_at_add),
|
|
1666
|
+
))
|
|
1667
|
+
section_results.append({
|
|
1668
|
+
"snapshot_id": f"{idx:02d}",
|
|
1669
|
+
"drift_detected": digest_now != digest_at_add,
|
|
1670
|
+
"data_digest_at_add": digest_at_add,
|
|
1671
|
+
"data_digest_now": digest_now,
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
compose_opts = ls.ComposeOptions(
|
|
1675
|
+
title=title, theme=theme, format=fmt,
|
|
1676
|
+
no_branding=no_branding, reveal_projects=reveal_projects,
|
|
1677
|
+
)
|
|
1678
|
+
try:
|
|
1679
|
+
body = ls.compose(tuple(composed_sections), opts=compose_opts)
|
|
1680
|
+
except Exception as exc:
|
|
1681
|
+
handler._respond_json(500, {"error": f"compose failed: {exc}"})
|
|
1682
|
+
return
|
|
1683
|
+
|
|
1684
|
+
content_type = {
|
|
1685
|
+
"md": "text/markdown",
|
|
1686
|
+
"html": "text/html",
|
|
1687
|
+
"svg": "image/svg+xml",
|
|
1688
|
+
}[fmt]
|
|
1689
|
+
handler._respond_json(200, {
|
|
1690
|
+
"body": body,
|
|
1691
|
+
"content_type": content_type,
|
|
1692
|
+
"snapshot": {
|
|
1693
|
+
"kernel_version": ls.KERNEL_VERSION,
|
|
1694
|
+
"composed_at": _share_now_utc_iso(),
|
|
1695
|
+
"section_results": section_results,
|
|
1696
|
+
},
|
|
1697
|
+
})
|
|
1698
|
+
|
|
1699
|
+
# ---- /api/share/presets — saved-recipe CRUD (spec §5.1, §11.3) ----
|
|
1700
|
+
#
|
|
1701
|
+
# GET /api/share/presets → list, grouped by panel
|
|
1702
|
+
# POST /api/share/presets → upsert (panel, name)
|
|
1703
|
+
# DELETE /api/share/presets/{panel}/{name} → remove one preset
|
|
1704
|
+
#
|
|
1705
|
+
# Persistence: `config.json` under `share.presets[<panel>][<name>]` so
|
|
1706
|
+
# the CLI can read them later (CLI consumer is designed for, not
|
|
1707
|
+
# shipped — out of scope per spec §15). GET is unauthenticated like
|
|
1708
|
+
# `/api/share/templates`; POST + DELETE go through `_check_origin_csrf`
|
|
1709
|
+
# (same gate as `/api/sync`, `/api/settings`, `/api/alerts/test`).
|
|
1710
|
+
# Write discipline: `config_writer_lock` + `_load_config_unlocked` +
|
|
1711
|
+
# `save_config` (atomic `os.replace`). Never call `load_config` from
|
|
1712
|
+
# inside the writer lock — `fcntl.flock` is per-fd and would
|
|
1713
|
+
# self-deadlock; see `_cmd_config_set` for the established pattern.
|
|
1714
|
+
|
|
1715
|
+
def _handle_share_presets_get_impl(handler) -> None:
|
|
1716
|
+
"""List saved share presets, grouped by panel (spec §5.1, §11.3).
|
|
1717
|
+
|
|
1718
|
+
Read-only — no CSRF gate. `config.json` may not contain the
|
|
1719
|
+
`share.presets` key on first run; returns `{"presets": {}}` then.
|
|
1720
|
+
"""
|
|
1721
|
+
cfg = sys.modules["cctally"].load_config()
|
|
1722
|
+
presets = (cfg.get("share") or {}).get("presets") or {}
|
|
1723
|
+
handler._respond_json(200, {"presets": presets})
|
|
1724
|
+
|
|
1725
|
+
def _handle_share_presets_post_impl(handler) -> None:
|
|
1726
|
+
"""Create or overwrite a preset (idempotent on `(panel, name)`).
|
|
1727
|
+
|
|
1728
|
+
Body: ``{panel, name, template_id, options}``. CSRF-gated.
|
|
1729
|
+
|
|
1730
|
+
Persistence is a read-modify-write under ``config_writer_lock`` +
|
|
1731
|
+
``_load_config_unlocked``. The plain ``load_config`` would
|
|
1732
|
+
self-deadlock on the same fcntl.flock fd; see the CLAUDE.md
|
|
1733
|
+
config-write invariant and `_cmd_config_set` for the canonical
|
|
1734
|
+
pattern.
|
|
1735
|
+
"""
|
|
1736
|
+
if not handler._check_origin_csrf():
|
|
1737
|
+
return
|
|
1738
|
+
try:
|
|
1739
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
1740
|
+
except ValueError:
|
|
1741
|
+
length = 0
|
|
1742
|
+
if length > _SHARE_POST_MAX_BYTES:
|
|
1743
|
+
# #279 S1 F3: bound the body before reading it (memory/slow-loris).
|
|
1744
|
+
# length == 0 stays allowed below (empty body -> {}); cap only the top.
|
|
1745
|
+
handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
|
|
1746
|
+
return
|
|
1747
|
+
try:
|
|
1748
|
+
raw = handler.rfile.read(length) if length > 0 else b""
|
|
1749
|
+
req = json.loads(raw) if raw else {}
|
|
1750
|
+
except (ValueError, json.JSONDecodeError):
|
|
1751
|
+
handler._respond_json(400, {"error": "malformed json"})
|
|
1752
|
+
return
|
|
1753
|
+
if not isinstance(req, dict):
|
|
1754
|
+
handler._respond_json(400, {"error": "expected JSON object"})
|
|
1755
|
+
return
|
|
1756
|
+
panel = req.get("panel")
|
|
1757
|
+
name = req.get("name")
|
|
1758
|
+
template_id = req.get("template_id")
|
|
1759
|
+
options = req.get("options")
|
|
1760
|
+
if not isinstance(panel, str) or not panel:
|
|
1761
|
+
handler._respond_json(400, {
|
|
1762
|
+
"error": "missing or non-string panel",
|
|
1763
|
+
"field": "panel",
|
|
1764
|
+
})
|
|
1765
|
+
return
|
|
1766
|
+
tpl_mod = handler._share_load_templates_module()
|
|
1767
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
1768
|
+
handler._respond_json(400, {
|
|
1769
|
+
"error": f"unknown share panel: {panel!r}",
|
|
1770
|
+
"field": "panel",
|
|
1771
|
+
})
|
|
1772
|
+
return
|
|
1773
|
+
if not isinstance(name, str) or not name or "/" in name or len(name) > 64:
|
|
1774
|
+
handler._respond_json(400, {
|
|
1775
|
+
"error": "name must be 1-64 chars and contain no '/'",
|
|
1776
|
+
"field": "name",
|
|
1777
|
+
})
|
|
1778
|
+
return
|
|
1779
|
+
if not isinstance(template_id, str) or not template_id:
|
|
1780
|
+
handler._respond_json(400, {
|
|
1781
|
+
"error": "missing or non-string template_id",
|
|
1782
|
+
"field": "template_id",
|
|
1783
|
+
})
|
|
1784
|
+
return
|
|
1785
|
+
try:
|
|
1786
|
+
template = tpl_mod.get_template(template_id)
|
|
1787
|
+
except KeyError:
|
|
1788
|
+
handler._respond_json(400, {
|
|
1789
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
1790
|
+
"field": "template_id",
|
|
1791
|
+
})
|
|
1792
|
+
return
|
|
1793
|
+
if template.panel != panel:
|
|
1794
|
+
handler._respond_json(400, {
|
|
1795
|
+
"error": (
|
|
1796
|
+
f"template_id {template_id!r} belongs to panel "
|
|
1797
|
+
f"{template.panel!r}, not {panel!r}"
|
|
1798
|
+
),
|
|
1799
|
+
"field": "template_id",
|
|
1800
|
+
})
|
|
1801
|
+
return
|
|
1802
|
+
if not isinstance(options, dict):
|
|
1803
|
+
handler._respond_json(400, {
|
|
1804
|
+
"error": "options must be an object",
|
|
1805
|
+
"field": "options",
|
|
1806
|
+
})
|
|
1807
|
+
return
|
|
1808
|
+
|
|
1809
|
+
saved_at = _share_now_utc_iso()
|
|
1810
|
+
record = {"template_id": template_id, "options": options, "saved_at": saved_at}
|
|
1811
|
+
|
|
1812
|
+
with sys.modules["cctally"].config_writer_lock():
|
|
1813
|
+
cfg = _load_config_unlocked()
|
|
1814
|
+
share = cfg.setdefault("share", {})
|
|
1815
|
+
presets = share.setdefault("presets", {})
|
|
1816
|
+
panel_bucket = presets.setdefault(panel, {})
|
|
1817
|
+
panel_bucket[name] = record
|
|
1818
|
+
save_config(cfg)
|
|
1819
|
+
handler._respond_json(200, {"panel": panel, "name": name, **record})
|
|
1820
|
+
|
|
1821
|
+
def _handle_share_presets_delete_impl(handler) -> None:
|
|
1822
|
+
"""Remove a preset by `(panel, name)`.
|
|
1823
|
+
|
|
1824
|
+
Path: ``/api/share/presets/{panel}/{name}``. Missing → 404 so
|
|
1825
|
+
DELETE stays meaningful for idempotency-aware clients. CSRF-gated.
|
|
1826
|
+
"""
|
|
1827
|
+
if not handler._check_origin_csrf():
|
|
1828
|
+
return
|
|
1829
|
+
import urllib.parse as _urlparse
|
|
1830
|
+
# Strip the query string defensively; the spec only uses path
|
|
1831
|
+
# segments but a stray "?" shouldn't poison the name token.
|
|
1832
|
+
path_only = handler.path.split("?", 1)[0]
|
|
1833
|
+
parts = path_only.split("/")
|
|
1834
|
+
# Expected: ["", "api", "share", "presets", "<panel>", "<name>"]
|
|
1835
|
+
if (
|
|
1836
|
+
len(parts) != 6
|
|
1837
|
+
or parts[1] != "api"
|
|
1838
|
+
or parts[2] != "share"
|
|
1839
|
+
or parts[3] != "presets"
|
|
1840
|
+
or not parts[4]
|
|
1841
|
+
or not parts[5]
|
|
1842
|
+
):
|
|
1843
|
+
handler._respond_json(400, {"error": "malformed delete path"})
|
|
1844
|
+
return
|
|
1845
|
+
panel = _urlparse.unquote(parts[4])
|
|
1846
|
+
name = _urlparse.unquote(parts[5])
|
|
1847
|
+
with sys.modules["cctally"].config_writer_lock():
|
|
1848
|
+
cfg = _load_config_unlocked()
|
|
1849
|
+
share = cfg.get("share") or {}
|
|
1850
|
+
presets = share.get("presets") or {}
|
|
1851
|
+
panel_bucket = presets.get(panel) or {}
|
|
1852
|
+
if name not in panel_bucket:
|
|
1853
|
+
handler._respond_json(404, {"error": "no such preset"})
|
|
1854
|
+
return
|
|
1855
|
+
del panel_bucket[name]
|
|
1856
|
+
# Tidy empty buckets so GET stays clean.
|
|
1857
|
+
if not panel_bucket:
|
|
1858
|
+
presets.pop(panel, None)
|
|
1859
|
+
save_config(cfg)
|
|
1860
|
+
handler.send_response(204)
|
|
1861
|
+
handler.send_header("Content-Length", "0")
|
|
1862
|
+
handler.end_headers()
|
|
1863
|
+
|
|
1864
|
+
# ---- /api/share/history — export-recipe ring buffer (spec §5.1, §11.4) ----
|
|
1865
|
+
#
|
|
1866
|
+
# GET /api/share/history → list (newest last) of last 20 export recipes
|
|
1867
|
+
# POST /api/share/history → append; server-side FIFO trim to 20
|
|
1868
|
+
# DELETE /api/share/history → clear the entire buffer
|
|
1869
|
+
#
|
|
1870
|
+
# Persisted under `share.history` in `config.json`. Write discipline
|
|
1871
|
+
# matches the presets handlers above: `config_writer_lock` +
|
|
1872
|
+
# `_load_config_unlocked` + `save_config`. GET is unauthenticated
|
|
1873
|
+
# like `/api/share/templates`; POST + DELETE go through
|
|
1874
|
+
# `_check_origin_csrf`. The frontend posts fire-and-forget after
|
|
1875
|
+
# every successful export — history failures are non-fatal.
|
|
1876
|
+
|
|
1877
|
+
def _handle_share_history_get_impl(handler) -> None:
|
|
1878
|
+
"""Return the recent-shares ring buffer (newest last, spec §11.4)."""
|
|
1879
|
+
cfg = sys.modules["cctally"].load_config()
|
|
1880
|
+
history = (cfg.get("share") or {}).get("history") or []
|
|
1881
|
+
handler._respond_json(200, {"history": history})
|
|
1882
|
+
|
|
1883
|
+
def _handle_share_history_post_impl(handler) -> None:
|
|
1884
|
+
"""Append a recipe to the ring buffer; FIFO trim to 20.
|
|
1885
|
+
|
|
1886
|
+
Body: ``{panel, template_id, options, format, destination}``. The
|
|
1887
|
+
server stamps ``recipe_id`` (random hex) and ``exported_at``
|
|
1888
|
+
(UTC ISO-8601) so the client doesn't need a clock or a UUID lib.
|
|
1889
|
+
CSRF-gated. Read-modify-write under ``config_writer_lock`` —
|
|
1890
|
+
same pattern as the presets POST.
|
|
1891
|
+
"""
|
|
1892
|
+
if not handler._check_origin_csrf():
|
|
1893
|
+
return
|
|
1894
|
+
try:
|
|
1895
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
1896
|
+
except ValueError:
|
|
1897
|
+
length = 0
|
|
1898
|
+
if length > _SHARE_POST_MAX_BYTES:
|
|
1899
|
+
# #279 S1 F3: bound the body before reading it (memory/slow-loris).
|
|
1900
|
+
# length == 0 stays allowed below (empty body -> {}); cap only the top.
|
|
1901
|
+
handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
|
|
1902
|
+
return
|
|
1903
|
+
try:
|
|
1904
|
+
raw = handler.rfile.read(length) if length > 0 else b""
|
|
1905
|
+
req = json.loads(raw) if raw else {}
|
|
1906
|
+
except (ValueError, json.JSONDecodeError):
|
|
1907
|
+
handler._respond_json(400, {"error": "malformed json"})
|
|
1908
|
+
return
|
|
1909
|
+
if not isinstance(req, dict):
|
|
1910
|
+
handler._respond_json(400, {"error": "expected JSON object"})
|
|
1911
|
+
return
|
|
1912
|
+
panel = req.get("panel")
|
|
1913
|
+
template_id = req.get("template_id")
|
|
1914
|
+
options = req.get("options") or {}
|
|
1915
|
+
fmt = req.get("format")
|
|
1916
|
+
destination = req.get("destination")
|
|
1917
|
+
if not isinstance(panel, str) or not panel:
|
|
1918
|
+
handler._respond_json(400, {
|
|
1919
|
+
"error": "missing or non-string panel",
|
|
1920
|
+
"field": "panel",
|
|
1921
|
+
})
|
|
1922
|
+
return
|
|
1923
|
+
tpl_mod = handler._share_load_templates_module()
|
|
1924
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
1925
|
+
handler._respond_json(400, {
|
|
1926
|
+
"error": f"unknown share panel: {panel!r}",
|
|
1927
|
+
"field": "panel",
|
|
1928
|
+
})
|
|
1929
|
+
return
|
|
1930
|
+
if not isinstance(template_id, str) or not template_id:
|
|
1931
|
+
handler._respond_json(400, {
|
|
1932
|
+
"error": "missing or non-string template_id",
|
|
1933
|
+
"field": "template_id",
|
|
1934
|
+
})
|
|
1935
|
+
return
|
|
1936
|
+
try:
|
|
1937
|
+
template = tpl_mod.get_template(template_id)
|
|
1938
|
+
except KeyError:
|
|
1939
|
+
handler._respond_json(400, {
|
|
1940
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
1941
|
+
"field": "template_id",
|
|
1942
|
+
})
|
|
1943
|
+
return
|
|
1944
|
+
if template.panel != panel:
|
|
1945
|
+
handler._respond_json(400, {
|
|
1946
|
+
"error": (
|
|
1947
|
+
f"template_id {template_id!r} belongs to panel "
|
|
1948
|
+
f"{template.panel!r}, not {panel!r}"
|
|
1949
|
+
),
|
|
1950
|
+
"field": "template_id",
|
|
1951
|
+
})
|
|
1952
|
+
return
|
|
1953
|
+
if not isinstance(options, dict):
|
|
1954
|
+
handler._respond_json(400, {
|
|
1955
|
+
"error": "options must be an object",
|
|
1956
|
+
"field": "options",
|
|
1957
|
+
})
|
|
1958
|
+
return
|
|
1959
|
+
# `format` and `destination` are advisory strings — accept any
|
|
1960
|
+
# non-empty string; the frontend uses them only as display hints
|
|
1961
|
+
# in the dropdown row. None/missing is allowed (mirrors how the
|
|
1962
|
+
# CLI doesn't always know which destination produced the export).
|
|
1963
|
+
if fmt is not None and not isinstance(fmt, str):
|
|
1964
|
+
handler._respond_json(400, {
|
|
1965
|
+
"error": "format must be a string if provided",
|
|
1966
|
+
"field": "format",
|
|
1967
|
+
})
|
|
1968
|
+
return
|
|
1969
|
+
if destination is not None and not isinstance(destination, str):
|
|
1970
|
+
handler._respond_json(400, {
|
|
1971
|
+
"error": "destination must be a string if provided",
|
|
1972
|
+
"field": "destination",
|
|
1973
|
+
})
|
|
1974
|
+
return
|
|
1975
|
+
|
|
1976
|
+
record = {
|
|
1977
|
+
"recipe_id": _share_history_recipe_id(),
|
|
1978
|
+
"panel": panel,
|
|
1979
|
+
"template_id": template_id,
|
|
1980
|
+
"options": options,
|
|
1981
|
+
"format": fmt,
|
|
1982
|
+
"destination": destination,
|
|
1983
|
+
"exported_at": _share_now_utc_iso(),
|
|
1984
|
+
}
|
|
1985
|
+
with sys.modules["cctally"].config_writer_lock():
|
|
1986
|
+
cfg = _load_config_unlocked()
|
|
1987
|
+
share = cfg.setdefault("share", {})
|
|
1988
|
+
history = share.setdefault("history", [])
|
|
1989
|
+
history.append(record)
|
|
1990
|
+
# Ring buffer: trim from the front so the newest is always
|
|
1991
|
+
# last. `del history[:n]` keeps the same list instance, so
|
|
1992
|
+
# callers holding a reference (none in this scope, but a
|
|
1993
|
+
# safe invariant) see the same object mutated in place.
|
|
1994
|
+
_ring_cap = sys.modules["cctally"]._SHARE_HISTORY_RING_CAP
|
|
1995
|
+
if len(history) > _ring_cap:
|
|
1996
|
+
del history[: len(history) - _ring_cap]
|
|
1997
|
+
save_config(cfg)
|
|
1998
|
+
handler._respond_json(200, record)
|
|
1999
|
+
|
|
2000
|
+
def _handle_share_history_delete_impl(handler) -> None:
|
|
2001
|
+
"""Empty the share-history ring buffer (spec §11.4)."""
|
|
2002
|
+
if not handler._check_origin_csrf():
|
|
2003
|
+
return
|
|
2004
|
+
with sys.modules["cctally"].config_writer_lock():
|
|
2005
|
+
cfg = _load_config_unlocked()
|
|
2006
|
+
share = cfg.get("share")
|
|
2007
|
+
if isinstance(share, dict) and "history" in share:
|
|
2008
|
+
share["history"] = []
|
|
2009
|
+
save_config(cfg)
|
|
2010
|
+
handler.send_response(204)
|
|
2011
|
+
handler.send_header("Content-Length", "0")
|
|
2012
|
+
handler.end_headers()
|