cctally 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/cctally +1653 -0
- package/dashboard/static/assets/index-Z6V0XgqK.js +18 -0
- package/dashboard/static/assets/index-ZPC0pk-h.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-D04GnY3n.css +0 -1
- package/dashboard/static/assets/index-Y2WlBP34.js +0 -18
package/bin/cctally
CHANGED
|
@@ -27638,6 +27638,33 @@ def _share_now_utc() -> dt.datetime:
|
|
|
27638
27638
|
return dt.datetime.now(dt.timezone.utc)
|
|
27639
27639
|
|
|
27640
27640
|
|
|
27641
|
+
def _share_now_utc_iso() -> str:
|
|
27642
|
+
"""`generated_at` ISO-8601 source for /api/share/render snapshot envelopes.
|
|
27643
|
+
|
|
27644
|
+
Honors `CCTALLY_AS_OF` like `_share_now_utc` so fixture goldens stay
|
|
27645
|
+
deterministic across the CLI and HTTP paths. Format `YYYY-MM-DDTHH:MM:SSZ`.
|
|
27646
|
+
"""
|
|
27647
|
+
return _share_now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
27648
|
+
|
|
27649
|
+
|
|
27650
|
+
# Spec §11.4 — recent-shares ring buffer caps at 20. Server-side trim
|
|
27651
|
+
# in `_handle_share_history_post` so the on-disk `config.json` can't
|
|
27652
|
+
# grow unbounded even if a misbehaving client floods POSTs.
|
|
27653
|
+
_SHARE_HISTORY_RING_CAP = 20
|
|
27654
|
+
|
|
27655
|
+
|
|
27656
|
+
def _share_history_recipe_id() -> str:
|
|
27657
|
+
"""Server-stamped opaque id for a history record.
|
|
27658
|
+
|
|
27659
|
+
Random base16 (26 chars / 13 bytes) is sufficient: we order by
|
|
27660
|
+
insertion (ring buffer position), never by id, so we don't need
|
|
27661
|
+
ULID timestamp-prefix monotonicity. `secrets.token_hex` keeps us
|
|
27662
|
+
on stdlib and avoids the predictability of `random`.
|
|
27663
|
+
"""
|
|
27664
|
+
import secrets
|
|
27665
|
+
return secrets.token_hex(13)
|
|
27666
|
+
|
|
27667
|
+
|
|
27641
27668
|
def _share_resolve_version() -> str:
|
|
27642
27669
|
"""Source from CHANGELOG via the existing release helper. Empty string if unset.
|
|
27643
27670
|
|
|
@@ -28916,6 +28943,851 @@ def _build_session_snapshot(
|
|
|
28916
28943
|
)
|
|
28917
28944
|
|
|
28918
28945
|
|
|
28946
|
+
# ---- v2 share panel_data builders (spec §5.2, plan M1.6) -------------
|
|
28947
|
+
#
|
|
28948
|
+
# These translate the live dashboard `DataSnapshot` into the dict shapes
|
|
28949
|
+
# the M1.4 Recap builders (in `bin/_lib_share_templates.py`) consume.
|
|
28950
|
+
# They're a thin extract step — the DataSnapshot was already built by
|
|
28951
|
+
# the sync thread, so this path doesn't re-query the DB on the share
|
|
28952
|
+
# hot path.
|
|
28953
|
+
#
|
|
28954
|
+
# Per-panel shape contracts live in each Recap builder's docstring in
|
|
28955
|
+
# `bin/_lib_share_templates.py` (see `_build_<panel>_recap`); the keys
|
|
28956
|
+
# below MUST stay in lockstep with those docstrings — the
|
|
28957
|
+
# producer/consumer contract.
|
|
28958
|
+
#
|
|
28959
|
+
# When the snapshot has no data for a given panel (fresh install, no
|
|
28960
|
+
# sync yet), the builder returns a minimal empty-shaped dict that the
|
|
28961
|
+
# downstream Recap builder renders as a "no data" snapshot (kernel
|
|
28962
|
+
# handles empty `weeks=[]` / `days=[]` / etc.).
|
|
28963
|
+
|
|
28964
|
+
|
|
28965
|
+
def _share_iso(value) -> "str | None":
|
|
28966
|
+
"""Coerce a datetime / ISO-string into an ISO-8601 string with `Z` suffix.
|
|
28967
|
+
|
|
28968
|
+
DataSnapshot mixes attribute types (`week_start_at` is a
|
|
28969
|
+
`dt.datetime`; `WeeklyPeriodRow.week_start_at` is already a string).
|
|
28970
|
+
Recap builders' `_parse_iso_utc` accepts both shapes via fromisoformat
|
|
28971
|
+
+ `Z`-swap, but normalizing here keeps the wire format consistent.
|
|
28972
|
+
"""
|
|
28973
|
+
if value is None:
|
|
28974
|
+
return None
|
|
28975
|
+
if isinstance(value, dt.datetime):
|
|
28976
|
+
v = value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)
|
|
28977
|
+
return v.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
28978
|
+
return str(value)
|
|
28979
|
+
|
|
28980
|
+
|
|
28981
|
+
# ---- Period override (spec §6.2 Q4 + Codex P2 on PR #35) ----
|
|
28982
|
+
#
|
|
28983
|
+
# The share modal's Period control offers three kinds — current, previous,
|
|
28984
|
+
# custom — but the original render path consumed the dashboard's cached
|
|
28985
|
+
# DataSnapshot directly, which only ever holds "current" data. Override
|
|
28986
|
+
# semantics by panel:
|
|
28987
|
+
#
|
|
28988
|
+
# panel current previous custom (start/end)
|
|
28989
|
+
# -------- ------------------ -------------------- -------------------
|
|
28990
|
+
# weekly this subscription one week earlier week containing end
|
|
28991
|
+
# week
|
|
28992
|
+
# daily last 7 display-tz 7 days earlier 7 days ending at end
|
|
28993
|
+
# days ending today
|
|
28994
|
+
# monthly last 12 months 12 months earlier 12 months ending at end
|
|
28995
|
+
# ending now
|
|
28996
|
+
# trend last 8 weeks 8 weeks earlier 8 weeks ending at end
|
|
28997
|
+
# ending now
|
|
28998
|
+
# blocks recent 5h blocks blocks ending one blocks ending at end
|
|
28999
|
+
# 5h-window earlier
|
|
29000
|
+
# forecast future projection (rejected: previous
|
|
29001
|
+
# from now forecast doesn't exist)
|
|
29002
|
+
# current-week this subscription (rejected: panel IS current)
|
|
29003
|
+
# week
|
|
29004
|
+
# sessions recent sessions (deferred: ambiguous semantics — could
|
|
29005
|
+
# mean "older sessions" or "sessions in
|
|
29006
|
+
# date range"; revisit when use case clear)
|
|
29007
|
+
#
|
|
29008
|
+
# Override mechanics: derive a `now_utc` from the period option and
|
|
29009
|
+
# re-build only the relevant DataSnapshot field by calling the same
|
|
29010
|
+
# `_dashboard_build_*` function the sync thread uses, just with a
|
|
29011
|
+
# shifted `now_utc`. `dataclasses.replace` returns a new DataSnapshot
|
|
29012
|
+
# with that field swapped; everything downstream (panel_data builder,
|
|
29013
|
+
# template builder, kernel render) consumes it unchanged.
|
|
29014
|
+
#
|
|
29015
|
+
# Validation failures land on the request as HTTP 400 with
|
|
29016
|
+
# `field: "options.period.<key>"` so the UI can highlight the offending
|
|
29017
|
+
# control.
|
|
29018
|
+
|
|
29019
|
+
_SHARE_PANELS_PERIOD_FIXED = ("forecast", "current-week", "sessions")
|
|
29020
|
+
# Panels whose period is intrinsic to the panel's identity. We accept
|
|
29021
|
+
# `kind="current"` (= no override) and reject anything else with 400.
|
|
29022
|
+
|
|
29023
|
+
_SHARE_PANELS_PERIOD_OVERRIDABLE = ("weekly", "daily", "monthly", "trend", "blocks")
|
|
29024
|
+
|
|
29025
|
+
|
|
29026
|
+
def _share_resolve_period(panel: str, options: dict):
|
|
29027
|
+
"""Return (now_utc_override, start_override, error_dict) for the period.
|
|
29028
|
+
|
|
29029
|
+
- `(None, None, None)` — no override needed (period absent or
|
|
29030
|
+
`kind="current"`). Caller continues with the cached DataSnapshot.
|
|
29031
|
+
- `(datetime, None, None)` — `kind="previous"`. Caller rebuilds with
|
|
29032
|
+
this `now_utc`; window length stays at the panel default.
|
|
29033
|
+
- `(datetime, datetime, None)` — `kind="custom"`. Caller rebuilds
|
|
29034
|
+
with `now_utc = end_dt` AND a derived window length spanning
|
|
29035
|
+
`[start_dt, end_dt]` (computed by `_share_apply_period_override`
|
|
29036
|
+
per panel). Spec §6.3 advertises "Custom (start–end pickers)";
|
|
29037
|
+
honoring the start picker means the rendered window's left edge
|
|
29038
|
+
moves with it. The 2-tuple form silently ignored `start_dt`.
|
|
29039
|
+
- `(None, None, {...})` — validation failure; caller emits 400.
|
|
29040
|
+
|
|
29041
|
+
`parse_iso_datetime` (the same parser used by every other share
|
|
29042
|
+
surface) accepts trailing `Z` / `+HH:MM` and naive forms. Naive
|
|
29043
|
+
inputs are treated as UTC by `parse_iso_datetime` and downstream
|
|
29044
|
+
UTC-fixup, so a date-only string like ``"2026-05-04"`` lands at
|
|
29045
|
+
midnight UTC.
|
|
29046
|
+
"""
|
|
29047
|
+
period = options.get("period")
|
|
29048
|
+
if period is None or not isinstance(period, dict):
|
|
29049
|
+
# Absent → no override, defaults to current. (Permissive: the
|
|
29050
|
+
# UI always sends a period block, but older basket recipes /
|
|
29051
|
+
# CLI parity may omit it.)
|
|
29052
|
+
return (None, None, None)
|
|
29053
|
+
kind = period.get("kind", "current")
|
|
29054
|
+
if kind not in ("current", "previous", "custom"):
|
|
29055
|
+
return (None, None, {"error": f"unknown period kind: {kind!r}",
|
|
29056
|
+
"field": "options.period.kind"})
|
|
29057
|
+
if panel in _SHARE_PANELS_PERIOD_FIXED:
|
|
29058
|
+
if kind != "current":
|
|
29059
|
+
return (None, None, {
|
|
29060
|
+
"error": (f"panel {panel!r} only supports period kind='current'; "
|
|
29061
|
+
f"got {kind!r}"),
|
|
29062
|
+
"field": "options.period.kind",
|
|
29063
|
+
})
|
|
29064
|
+
return (None, None, None)
|
|
29065
|
+
# Overridable panels — handle each kind.
|
|
29066
|
+
if kind == "current":
|
|
29067
|
+
return (None, None, None)
|
|
29068
|
+
if kind == "previous":
|
|
29069
|
+
delta = _share_previous_period_delta(panel)
|
|
29070
|
+
return (_share_now_utc() - delta, None, None)
|
|
29071
|
+
# kind == "custom"
|
|
29072
|
+
start_str = period.get("start")
|
|
29073
|
+
end_str = period.get("end")
|
|
29074
|
+
if not isinstance(start_str, str) or not start_str \
|
|
29075
|
+
or not isinstance(end_str, str) or not end_str:
|
|
29076
|
+
return (None, None, {
|
|
29077
|
+
"error": "custom period requires non-empty start + end ISO dates",
|
|
29078
|
+
"field": "options.period",
|
|
29079
|
+
})
|
|
29080
|
+
try:
|
|
29081
|
+
start_dt = parse_iso_datetime(start_str, "options.period.start")
|
|
29082
|
+
end_dt = parse_iso_datetime(end_str, "options.period.end")
|
|
29083
|
+
except ValueError as exc:
|
|
29084
|
+
return (None, None, {"error": f"invalid period date: {exc}",
|
|
29085
|
+
"field": "options.period"})
|
|
29086
|
+
if end_dt <= start_dt:
|
|
29087
|
+
return (None, None, {
|
|
29088
|
+
"error": ("custom period end must be strictly after start "
|
|
29089
|
+
f"(got start={start_str!r}, end={end_str!r})"),
|
|
29090
|
+
"field": "options.period",
|
|
29091
|
+
})
|
|
29092
|
+
return (end_dt, start_dt, None)
|
|
29093
|
+
|
|
29094
|
+
|
|
29095
|
+
def _share_custom_window_n(panel: str, start_dt: "dt.datetime",
|
|
29096
|
+
end_dt: "dt.datetime") -> int:
|
|
29097
|
+
"""Per-panel window length covering `[start_dt, end_dt]`, min 1.
|
|
29098
|
+
|
|
29099
|
+
Each overridable panel exposes a different unit:
|
|
29100
|
+
- weekly / trend → weeks
|
|
29101
|
+
- daily → days (inclusive)
|
|
29102
|
+
- monthly → calendar months (inclusive)
|
|
29103
|
+
Blocks doesn't use this helper — its builder is window-anchored via
|
|
29104
|
+
`week_start_at`/`week_end_at`, not `n`, so we pass `start_dt`/`end_dt`
|
|
29105
|
+
directly to `_dashboard_build_blocks_panel`.
|
|
29106
|
+
|
|
29107
|
+
Inputs are timezone-aware UTC datetimes (`parse_iso_datetime` UTCs
|
|
29108
|
+
naive inputs upstream). Math is purely on the timedelta + calendar
|
|
29109
|
+
diffs; `_dashboard_build_monthly_periods` does its own display-tz
|
|
29110
|
+
bucketing on the resulting window.
|
|
29111
|
+
"""
|
|
29112
|
+
import math as _math
|
|
29113
|
+
delta_seconds = (end_dt - start_dt).total_seconds()
|
|
29114
|
+
delta_days = _math.ceil(delta_seconds / 86400.0)
|
|
29115
|
+
if panel in ("weekly", "trend"):
|
|
29116
|
+
return max(1, _math.ceil(delta_days / 7))
|
|
29117
|
+
if panel == "daily":
|
|
29118
|
+
return max(1, int(delta_days))
|
|
29119
|
+
if panel == "monthly":
|
|
29120
|
+
months = ((end_dt.year - start_dt.year) * 12
|
|
29121
|
+
+ (end_dt.month - start_dt.month) + 1)
|
|
29122
|
+
return max(1, months)
|
|
29123
|
+
# Shouldn't reach here — `_share_apply_period_override` handles
|
|
29124
|
+
# blocks separately. Defensive: return 1 rather than raising.
|
|
29125
|
+
return 1
|
|
29126
|
+
|
|
29127
|
+
|
|
29128
|
+
def _share_previous_period_delta(panel: str) -> "dt.timedelta":
|
|
29129
|
+
"""How far back `now_utc` shifts for `kind='previous'` on each panel.
|
|
29130
|
+
|
|
29131
|
+
weekly/daily: 7 days. monthly: one whole month worth (we shift to
|
|
29132
|
+
the last day of the previous month at call time to handle variable
|
|
29133
|
+
month length, so this is unused — the caller routes through
|
|
29134
|
+
`_share_resolve_period` which special-cases monthly). trend: 8 weeks
|
|
29135
|
+
(one trend window). blocks: 5 hours (one block).
|
|
29136
|
+
"""
|
|
29137
|
+
if panel == "weekly":
|
|
29138
|
+
return dt.timedelta(days=7)
|
|
29139
|
+
if panel == "daily":
|
|
29140
|
+
return dt.timedelta(days=7)
|
|
29141
|
+
if panel == "monthly":
|
|
29142
|
+
return dt.timedelta(days=30) # close-enough for the resolver;
|
|
29143
|
+
# see _share_resolve_period_monthly
|
|
29144
|
+
# below for the calendar-aware
|
|
29145
|
+
# version when needed.
|
|
29146
|
+
if panel == "trend":
|
|
29147
|
+
return dt.timedelta(days=8 * 7)
|
|
29148
|
+
if panel == "blocks":
|
|
29149
|
+
return dt.timedelta(hours=5)
|
|
29150
|
+
raise ValueError(f"_share_previous_period_delta: no delta for panel {panel!r}")
|
|
29151
|
+
|
|
29152
|
+
|
|
29153
|
+
def _share_apply_period_override(panel: str, options: dict,
|
|
29154
|
+
snap: "DataSnapshot | None"):
|
|
29155
|
+
"""Return (snap_or_None, error_dict_or_None).
|
|
29156
|
+
|
|
29157
|
+
Walks `_share_resolve_period`, then re-builds the panel's DataSnapshot
|
|
29158
|
+
field from DB when an override is requested. `dataclasses.replace`
|
|
29159
|
+
yields a shallow copy with one field swapped. Returns the original
|
|
29160
|
+
`snap` unchanged when no override applies.
|
|
29161
|
+
"""
|
|
29162
|
+
if snap is None:
|
|
29163
|
+
# No cached snapshot to override against — return None unchanged
|
|
29164
|
+
# and let the panel_data builder's empty-snapshot path handle it.
|
|
29165
|
+
# Still validate the period option so the user gets a 400 on
|
|
29166
|
+
# malformed input even before the sync thread's first tick.
|
|
29167
|
+
_, _, err = _share_resolve_period(panel, options)
|
|
29168
|
+
return (snap, err)
|
|
29169
|
+
now_override, start_override, err = _share_resolve_period(panel, options)
|
|
29170
|
+
if err is not None:
|
|
29171
|
+
return (None, err)
|
|
29172
|
+
if now_override is None:
|
|
29173
|
+
return (snap, None)
|
|
29174
|
+
# For `kind="custom"`, derive a per-panel window length covering
|
|
29175
|
+
# `[start_override, now_override]` so the rendered window honors the
|
|
29176
|
+
# Start picker (spec §6.3). For `kind="previous"`, `start_override`
|
|
29177
|
+
# is None → window length stays at the panel's default.
|
|
29178
|
+
n_override = (
|
|
29179
|
+
_share_custom_window_n(panel, start_override, now_override)
|
|
29180
|
+
if start_override is not None else None
|
|
29181
|
+
)
|
|
29182
|
+
import dataclasses as _dc
|
|
29183
|
+
conn = open_db()
|
|
29184
|
+
try:
|
|
29185
|
+
if panel == "weekly":
|
|
29186
|
+
kwargs: dict = {"skip_sync": True}
|
|
29187
|
+
if n_override is not None:
|
|
29188
|
+
kwargs["n"] = n_override
|
|
29189
|
+
rows = _dashboard_build_weekly_periods(conn, now_override, **kwargs)
|
|
29190
|
+
return (_dc.replace(snap, weekly_periods=rows), None)
|
|
29191
|
+
if panel == "daily":
|
|
29192
|
+
display_tz_name = options.get("display_tz", "Etc/UTC")
|
|
29193
|
+
try:
|
|
29194
|
+
display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
|
|
29195
|
+
except Exception:
|
|
29196
|
+
display_tz = None
|
|
29197
|
+
kwargs = {"skip_sync": True, "display_tz": display_tz}
|
|
29198
|
+
if n_override is not None:
|
|
29199
|
+
kwargs["n"] = n_override
|
|
29200
|
+
rows = _dashboard_build_daily_panel(conn, now_override, **kwargs)
|
|
29201
|
+
return (_dc.replace(snap, daily_panel=rows), None)
|
|
29202
|
+
if panel == "monthly":
|
|
29203
|
+
kwargs = {"skip_sync": True}
|
|
29204
|
+
if n_override is not None:
|
|
29205
|
+
kwargs["n"] = n_override
|
|
29206
|
+
rows = _dashboard_build_monthly_periods(conn, now_override, **kwargs)
|
|
29207
|
+
return (_dc.replace(snap, monthly_periods=rows), None)
|
|
29208
|
+
if panel == "trend":
|
|
29209
|
+
kwargs = {"skip_sync": True}
|
|
29210
|
+
if n_override is not None:
|
|
29211
|
+
kwargs["count"] = n_override
|
|
29212
|
+
rows = _tui_build_trend(conn, now_override, **kwargs)
|
|
29213
|
+
return (_dc.replace(snap, trend=rows), None)
|
|
29214
|
+
if panel == "blocks":
|
|
29215
|
+
# `_dashboard_build_blocks_panel` is window-anchored via
|
|
29216
|
+
# `week_start_at`/`week_end_at`, not `n`. For `kind='custom'`,
|
|
29217
|
+
# use the user's [start_dt, end_dt] verbatim. For
|
|
29218
|
+
# `kind='previous'`, fall back to a 7-day window ending at
|
|
29219
|
+
# the override `now_utc` (the spec's prior-block semantics —
|
|
29220
|
+
# intentionally NOT aligned to subscription-week boundaries
|
|
29221
|
+
# since the share period override is wall-clock-aware, not
|
|
29222
|
+
# quota-aware).
|
|
29223
|
+
if start_override is not None:
|
|
29224
|
+
week_start_at = start_override
|
|
29225
|
+
week_end_at = now_override
|
|
29226
|
+
else:
|
|
29227
|
+
week_start_at = now_override - dt.timedelta(days=7)
|
|
29228
|
+
week_end_at = now_override
|
|
29229
|
+
rows = _dashboard_build_blocks_panel(
|
|
29230
|
+
conn, now_override,
|
|
29231
|
+
week_start_at=week_start_at,
|
|
29232
|
+
week_end_at=week_end_at,
|
|
29233
|
+
skip_sync=True,
|
|
29234
|
+
)
|
|
29235
|
+
return (_dc.replace(snap, blocks_panel=rows), None)
|
|
29236
|
+
# forecast / current-week / sessions: resolver already gated; we
|
|
29237
|
+
# only reach here for `kind="current"`, which returns no
|
|
29238
|
+
# override.
|
|
29239
|
+
return (snap, None)
|
|
29240
|
+
finally:
|
|
29241
|
+
conn.close()
|
|
29242
|
+
|
|
29243
|
+
|
|
29244
|
+
def _share_apply_content_toggles(snap_built, options: dict):
|
|
29245
|
+
"""Strip chart / table from a built ShareSnapshot per render options.
|
|
29246
|
+
|
|
29247
|
+
The render kernel consumes whatever the template builder emits, so
|
|
29248
|
+
chart/table on-off can't be expressed by the builder alone (every
|
|
29249
|
+
builder unconditionally emits both). Apply the toggle here, after
|
|
29250
|
+
the builder, before `_scrub` and `render`. ShareSnapshot is frozen;
|
|
29251
|
+
`dataclasses.replace` returns a new instance.
|
|
29252
|
+
|
|
29253
|
+
Defaults preserve pre-toggle behavior: `show_chart` defaults to
|
|
29254
|
+
True, `show_table` defaults to True. Explicit False on either
|
|
29255
|
+
drops the corresponding payload.
|
|
29256
|
+
"""
|
|
29257
|
+
import dataclasses as _dc
|
|
29258
|
+
show_chart = bool(options.get("show_chart", True))
|
|
29259
|
+
show_table = bool(options.get("show_table", True))
|
|
29260
|
+
changes: dict = {}
|
|
29261
|
+
if not show_chart:
|
|
29262
|
+
changes["chart"] = None
|
|
29263
|
+
if not show_table:
|
|
29264
|
+
changes["columns"] = ()
|
|
29265
|
+
changes["rows"] = ()
|
|
29266
|
+
if not changes:
|
|
29267
|
+
return snap_built
|
|
29268
|
+
return _dc.replace(snap_built, **changes)
|
|
29269
|
+
|
|
29270
|
+
|
|
29271
|
+
# Cap on how many `(project, cost)` rows builders return for top_projects.
|
|
29272
|
+
# Templates take `top_n` from options (default 5, see _lib_share_templates)
|
|
29273
|
+
# and apply their own cap on top of this. The headroom matters because:
|
|
29274
|
+
# (a) the scrubber walks ProjectCells once per row, so unbounded length
|
|
29275
|
+
# balloons render-time anonymization cost;
|
|
29276
|
+
# (b) the live preview iframe streams the full table chrome;
|
|
29277
|
+
# (c) 20 covers any realistic `top_n` knob value (UI typically caps at 10).
|
|
29278
|
+
_SHARE_TOP_PROJECTS_BUILDER_CAP = 20
|
|
29279
|
+
|
|
29280
|
+
|
|
29281
|
+
def _share_top_projects_for_range(
|
|
29282
|
+
range_start: "dt.datetime",
|
|
29283
|
+
range_end: "dt.datetime",
|
|
29284
|
+
*,
|
|
29285
|
+
skip_sync: bool = True,
|
|
29286
|
+
) -> list[tuple[str, float]]:
|
|
29287
|
+
"""Aggregate session_entries in `[range_start, range_end]` by project_path.
|
|
29288
|
+
|
|
29289
|
+
Returns `[(project_path_or_'(unknown)', cost_usd), ...]` sorted desc by
|
|
29290
|
+
cost and capped at `_SHARE_TOP_PROJECTS_BUILDER_CAP`. Templates apply
|
|
29291
|
+
a further `top_n` cap (default 5).
|
|
29292
|
+
|
|
29293
|
+
Routes through `get_claude_session_entries` so we get `project_path`
|
|
29294
|
+
in the join — same cache-first/lock-contention/direct-JSONL fallback
|
|
29295
|
+
chain the rest of the share path relies on. `skip_sync=True` by
|
|
29296
|
+
default: the sync thread has already done its tick at snapshot-build
|
|
29297
|
+
time, and a per-request ingest would block the share render on
|
|
29298
|
+
`cache.db.lock`.
|
|
29299
|
+
|
|
29300
|
+
Cost computation goes through `_calculate_entry_cost` — the
|
|
29301
|
+
single-source-of-truth pricing path. Mirrors `_compute_block_totals`'
|
|
29302
|
+
`by_project` bucketing exactly, so the reconcile invariant
|
|
29303
|
+
`SUM(top_projects) ≈ panel.cost_usd` is preserved within ULP drift
|
|
29304
|
+
when the panel's cost matches the same time range (e.g., current
|
|
29305
|
+
week, current 5h block).
|
|
29306
|
+
|
|
29307
|
+
NULL `project_path` collapses to the `(unknown)` sentinel. Anon
|
|
29308
|
+
happens later in `_scrub()`; builders always emit real names per
|
|
29309
|
+
the kernel's privacy chokepoint contract.
|
|
29310
|
+
"""
|
|
29311
|
+
bucket: dict[str, float] = {}
|
|
29312
|
+
try:
|
|
29313
|
+
entries = get_claude_session_entries(
|
|
29314
|
+
range_start, range_end, skip_sync=skip_sync,
|
|
29315
|
+
)
|
|
29316
|
+
except Exception:
|
|
29317
|
+
# `get_claude_session_entries` already has its own fallback chain,
|
|
29318
|
+
# but if even that fails (e.g., HOME unset in a fixture run with
|
|
29319
|
+
# no monkeypatch), don't break the whole share render — just emit
|
|
29320
|
+
# an empty top_projects.
|
|
29321
|
+
return []
|
|
29322
|
+
for entry in entries:
|
|
29323
|
+
usage = {
|
|
29324
|
+
"input_tokens": entry.input_tokens,
|
|
29325
|
+
"output_tokens": entry.output_tokens,
|
|
29326
|
+
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
29327
|
+
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
29328
|
+
}
|
|
29329
|
+
cost = _calculate_entry_cost(
|
|
29330
|
+
entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
|
|
29331
|
+
)
|
|
29332
|
+
key = entry.project_path or "(unknown)"
|
|
29333
|
+
bucket[key] = bucket.get(key, 0.0) + cost
|
|
29334
|
+
ranked = sorted(bucket.items(), key=lambda kv: -kv[1])
|
|
29335
|
+
return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
|
|
29336
|
+
|
|
29337
|
+
|
|
29338
|
+
def _build_share_panel_data(panel: str, options: dict,
|
|
29339
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29340
|
+
"""Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
|
|
29341
|
+
|
|
29342
|
+
Each per-panel builder reads from the already-built `DataSnapshot`
|
|
29343
|
+
rather than re-running CLI aggregation queries — keeps /api/share/render
|
|
29344
|
+
cheap and ensures the share artifact matches what the dashboard panel
|
|
29345
|
+
is currently showing.
|
|
29346
|
+
"""
|
|
29347
|
+
if panel == "weekly": return _build_weekly_share_panel_data(options, snap)
|
|
29348
|
+
if panel == "daily": return _build_daily_share_panel_data(options, snap)
|
|
29349
|
+
if panel == "monthly": return _build_monthly_share_panel_data(options, snap)
|
|
29350
|
+
if panel == "trend": return _build_trend_share_panel_data(options, snap)
|
|
29351
|
+
if panel == "forecast": return _build_forecast_share_panel_data(options, snap)
|
|
29352
|
+
if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
|
|
29353
|
+
if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
|
|
29354
|
+
if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
|
|
29355
|
+
raise ValueError(f"unknown share panel: {panel!r}")
|
|
29356
|
+
|
|
29357
|
+
|
|
29358
|
+
def _share_empty_week_stub() -> dict:
|
|
29359
|
+
"""Minimal week shape so empty snapshots render as "no data" cleanly.
|
|
29360
|
+
|
|
29361
|
+
Recap builders index `weeks[idx]` directly; supplying one zero-filled
|
|
29362
|
+
row keeps that access safe without leaking misleading numbers (the
|
|
29363
|
+
rendered artifact shows $0.00 / 0.0% — accurate for an empty install).
|
|
29364
|
+
"""
|
|
29365
|
+
return {
|
|
29366
|
+
"start_date": _share_now_utc().strftime("%Y-%m-%d"),
|
|
29367
|
+
"cost_usd": 0.0,
|
|
29368
|
+
"pct_used": 0.0,
|
|
29369
|
+
"dollar_per_pct": 0.0,
|
|
29370
|
+
"top_projects": [],
|
|
29371
|
+
}
|
|
29372
|
+
|
|
29373
|
+
|
|
29374
|
+
def _build_weekly_share_panel_data(options: dict,
|
|
29375
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29376
|
+
"""Weekly panel_data — last 8 subscription weeks + current-week index.
|
|
29377
|
+
|
|
29378
|
+
Reuses `DataSnapshot.weekly_periods` (WeeklyPeriodRow list), already
|
|
29379
|
+
built by `_dashboard_build_weekly_periods` in the sync thread. Empty
|
|
29380
|
+
snapshots emit a one-week stub so the Recap builder's `weeks[idx]`
|
|
29381
|
+
access stays safe (renders as $0.00 / 0.0% — accurate "no data").
|
|
29382
|
+
"""
|
|
29383
|
+
rows = list(getattr(snap, "weekly_periods", None) or []) if snap else []
|
|
29384
|
+
# weekly_periods is newest-first (see _dashboard_build_weekly_periods).
|
|
29385
|
+
# Take the newest 8 and reverse to oldest→newest — the Recap template
|
|
29386
|
+
# reads weeks[0] as the start anchor and weeks[-1] as the right-edge
|
|
29387
|
+
# (current-week) anchor, and current_week_index addresses that order.
|
|
29388
|
+
rows_8 = list(reversed(rows[:8]))
|
|
29389
|
+
weeks: list[dict] = []
|
|
29390
|
+
current_idx = 0
|
|
29391
|
+
for i, r in enumerate(rows_8):
|
|
29392
|
+
if getattr(r, "is_current", False):
|
|
29393
|
+
current_idx = i
|
|
29394
|
+
# WeeklyPeriodRow.week_start_at is an ISO datetime string; the
|
|
29395
|
+
# Recap shape wants a YYYY-MM-DD date label. Slice the leading
|
|
29396
|
+
# 10 chars (or fall back to parsing).
|
|
29397
|
+
wsa = getattr(r, "week_start_at", "") or ""
|
|
29398
|
+
start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
|
|
29399
|
+
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
29400
|
+
used_pct_raw = getattr(r, "used_pct", None)
|
|
29401
|
+
used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
|
|
29402
|
+
dpp = float(getattr(r, "dollar_per_pct", 0.0) or 0.0)
|
|
29403
|
+
# Per-week top_projects: WeeklyPeriodRow doesn't carry a
|
|
29404
|
+
# per-project rollup, but `week_start_at` / `week_end_at` give us
|
|
29405
|
+
# an exact range — aggregate session_entries once per week so the
|
|
29406
|
+
# Recap template's `weeks[i].top_projects` table is meaningful.
|
|
29407
|
+
# 8 queries per share render is the perf trade; cached.
|
|
29408
|
+
week_end_at = getattr(r, "week_end_at", "") or ""
|
|
29409
|
+
top_projects: list[tuple[str, float]] = []
|
|
29410
|
+
try:
|
|
29411
|
+
ws_dt = parse_iso_datetime(wsa, "week_start_at") if isinstance(wsa, str) and wsa else None
|
|
29412
|
+
we_dt = parse_iso_datetime(week_end_at, "week_end_at") if isinstance(week_end_at, str) and week_end_at else None
|
|
29413
|
+
except ValueError:
|
|
29414
|
+
ws_dt = we_dt = None
|
|
29415
|
+
if ws_dt is not None and we_dt is not None:
|
|
29416
|
+
top_projects = _share_top_projects_for_range(ws_dt, we_dt)
|
|
29417
|
+
weeks.append({
|
|
29418
|
+
"start_date": start_date,
|
|
29419
|
+
"cost_usd": cost,
|
|
29420
|
+
"pct_used": used_pct,
|
|
29421
|
+
"dollar_per_pct": dpp,
|
|
29422
|
+
"top_projects": top_projects,
|
|
29423
|
+
})
|
|
29424
|
+
if not weeks:
|
|
29425
|
+
weeks = [_share_empty_week_stub()]
|
|
29426
|
+
return {"weeks": weeks, "current_week_index": current_idx}
|
|
29427
|
+
|
|
29428
|
+
|
|
29429
|
+
def _build_current_week_share_panel_data(options: dict,
|
|
29430
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29431
|
+
"""Current-week panel_data — KPI strip + daily progression + top projects.
|
|
29432
|
+
|
|
29433
|
+
Synthesized from `DataSnapshot.current_week` + `daily_panel` (no 1:1
|
|
29434
|
+
CLI counterpart, per spec §9.5). `daily_progression` clips the daily
|
|
29435
|
+
panel to the current subscription week.
|
|
29436
|
+
"""
|
|
29437
|
+
cw = getattr(snap, "current_week", None) if snap else None
|
|
29438
|
+
daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
|
|
29439
|
+
if cw is None:
|
|
29440
|
+
# Empty-shape fallback — Recap builder renders "no data" gracefully.
|
|
29441
|
+
return {
|
|
29442
|
+
"kpi_cost_usd": 0.0,
|
|
29443
|
+
"kpi_pct_used": 0.0,
|
|
29444
|
+
"kpi_dollar_per_pct": 0.0,
|
|
29445
|
+
"kpi_days_remaining": 0.0,
|
|
29446
|
+
"daily_progression": [],
|
|
29447
|
+
"top_projects": [],
|
|
29448
|
+
"week_start_date": _share_now_utc().strftime("%Y-%m-%d"),
|
|
29449
|
+
"display_tz": options.get("display_tz", "Etc/UTC"),
|
|
29450
|
+
}
|
|
29451
|
+
week_start = getattr(cw, "week_start_at", None)
|
|
29452
|
+
week_end = getattr(cw, "week_end_at", None)
|
|
29453
|
+
week_start_date = (
|
|
29454
|
+
week_start.strftime("%Y-%m-%d") if isinstance(week_start, dt.datetime)
|
|
29455
|
+
else _share_now_utc().strftime("%Y-%m-%d")
|
|
29456
|
+
)
|
|
29457
|
+
# Days remaining = hours_to_reset / 24
|
|
29458
|
+
days_remaining = 0.0
|
|
29459
|
+
if isinstance(week_end, dt.datetime):
|
|
29460
|
+
remaining = (week_end - _share_now_utc()).total_seconds() / 86400.0
|
|
29461
|
+
days_remaining = max(0.0, remaining)
|
|
29462
|
+
used_pct = float(getattr(cw, "used_pct", 0.0) or 0.0) / 100.0
|
|
29463
|
+
progression: list[dict] = []
|
|
29464
|
+
if isinstance(week_start, dt.datetime):
|
|
29465
|
+
ws_date = week_start.date()
|
|
29466
|
+
# daily_panel is newest-first; iterate reversed so progression is
|
|
29467
|
+
# oldest→newest, matching the Recap template's progression[-1] =
|
|
29468
|
+
# today contract and the chart's left→right time axis.
|
|
29469
|
+
for r in reversed(daily):
|
|
29470
|
+
try:
|
|
29471
|
+
d = dt.date.fromisoformat(getattr(r, "date", "") or "")
|
|
29472
|
+
except ValueError:
|
|
29473
|
+
continue
|
|
29474
|
+
if d >= ws_date:
|
|
29475
|
+
progression.append({
|
|
29476
|
+
"date": d.isoformat(),
|
|
29477
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
29478
|
+
})
|
|
29479
|
+
# Current-week top_projects: aggregate from `[week_start, now]`.
|
|
29480
|
+
# `cw.week_end_at` is the reset instant; using `now` keeps the rollup
|
|
29481
|
+
# symmetric with the panel's "spent this week" KPI (week-to-date).
|
|
29482
|
+
top_projects: list[tuple[str, float]] = []
|
|
29483
|
+
if isinstance(week_start, dt.datetime):
|
|
29484
|
+
top_projects = _share_top_projects_for_range(
|
|
29485
|
+
week_start, _share_now_utc(),
|
|
29486
|
+
)
|
|
29487
|
+
return {
|
|
29488
|
+
"kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
|
|
29489
|
+
"kpi_pct_used": used_pct,
|
|
29490
|
+
"kpi_dollar_per_pct": float(getattr(cw, "dollars_per_percent", 0.0) or 0.0),
|
|
29491
|
+
"kpi_days_remaining": days_remaining,
|
|
29492
|
+
"daily_progression": progression,
|
|
29493
|
+
"top_projects": top_projects,
|
|
29494
|
+
"week_start_date": week_start_date,
|
|
29495
|
+
"display_tz": options.get("display_tz", "Etc/UTC"),
|
|
29496
|
+
}
|
|
29497
|
+
|
|
29498
|
+
|
|
29499
|
+
def _build_trend_share_panel_data(options: dict,
|
|
29500
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29501
|
+
"""Trend panel_data — 8 weeks of $/% + 3-week delta KPI.
|
|
29502
|
+
|
|
29503
|
+
Reuses `DataSnapshot.trend` (TuiTrendRow list, already 8 rows).
|
|
29504
|
+
"""
|
|
29505
|
+
trend = list(getattr(snap, "trend", None) or []) if snap else []
|
|
29506
|
+
weeks: list[dict] = []
|
|
29507
|
+
for r in trend:
|
|
29508
|
+
wsa = getattr(r, "week_start_at", None)
|
|
29509
|
+
start_date = (
|
|
29510
|
+
wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
|
|
29511
|
+
else (str(wsa)[:10] if wsa else "")
|
|
29512
|
+
)
|
|
29513
|
+
used_pct_raw = getattr(r, "used_pct", None)
|
|
29514
|
+
used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
|
|
29515
|
+
dpp = float(getattr(r, "dollars_per_percent", 0.0) or 0.0)
|
|
29516
|
+
weeks.append({
|
|
29517
|
+
"start_date": start_date,
|
|
29518
|
+
"cost_usd": dpp * (used_pct * 100.0), # ≈ row total
|
|
29519
|
+
"pct_used": used_pct,
|
|
29520
|
+
"dollar_per_pct": dpp,
|
|
29521
|
+
})
|
|
29522
|
+
# Compute 3-week delta: compare last row vs row-4-from-end.
|
|
29523
|
+
delta = {"dpp_change_pct": 0.0, "cost_change_usd": 0.0}
|
|
29524
|
+
if len(weeks) >= 4:
|
|
29525
|
+
cur = weeks[-1]
|
|
29526
|
+
ref = weeks[-4]
|
|
29527
|
+
if ref["dollar_per_pct"]:
|
|
29528
|
+
delta["dpp_change_pct"] = (
|
|
29529
|
+
(cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
|
|
29530
|
+
)
|
|
29531
|
+
delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
|
|
29532
|
+
return {"weeks": weeks, "delta_3_weeks": delta}
|
|
29533
|
+
|
|
29534
|
+
|
|
29535
|
+
def _build_daily_share_panel_data(options: dict,
|
|
29536
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29537
|
+
"""Daily panel_data — last 7 days with top model per day + top projects.
|
|
29538
|
+
|
|
29539
|
+
Reuses `DataSnapshot.daily_panel` (DailyPanelRow list, 30 rows in
|
|
29540
|
+
full); clips to the most recent 7 for the Recap.
|
|
29541
|
+
"""
|
|
29542
|
+
daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
|
|
29543
|
+
# daily_panel is newest-first (today at index 0); take the most recent
|
|
29544
|
+
# 7 and reverse to oldest→newest so the Recap template's days[-1]
|
|
29545
|
+
# anchor lands on today.
|
|
29546
|
+
last_7 = list(reversed(daily[:7]))
|
|
29547
|
+
total = sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
|
|
29548
|
+
days: list[dict] = []
|
|
29549
|
+
for r in last_7:
|
|
29550
|
+
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
29551
|
+
models = getattr(r, "models", None) or []
|
|
29552
|
+
top_model = (models[0].get("model") if models else None) or "—"
|
|
29553
|
+
days.append({
|
|
29554
|
+
"date": getattr(r, "date", "") or "",
|
|
29555
|
+
"cost_usd": cost,
|
|
29556
|
+
"pct_of_period": cost / total,
|
|
29557
|
+
"top_model": top_model,
|
|
29558
|
+
})
|
|
29559
|
+
# Daily top_projects: aggregate over the 7-day window. Derive the
|
|
29560
|
+
# range from the dates rendered above so the rollup covers exactly
|
|
29561
|
+
# what the panel shows (rather than re-deriving "7 days ago" from
|
|
29562
|
+
# now and potentially clipping the oldest bucket).
|
|
29563
|
+
top_projects: list[tuple[str, float]] = []
|
|
29564
|
+
if days:
|
|
29565
|
+
try:
|
|
29566
|
+
range_start = dt.datetime.fromisoformat(
|
|
29567
|
+
f"{days[0]['date']}T00:00:00+00:00"
|
|
29568
|
+
)
|
|
29569
|
+
# Include the last day in full — end-exclusive boundary at
|
|
29570
|
+
# the start of the next UTC day.
|
|
29571
|
+
last_date = dt.date.fromisoformat(days[-1]["date"])
|
|
29572
|
+
range_end = dt.datetime(
|
|
29573
|
+
last_date.year, last_date.month, last_date.day,
|
|
29574
|
+
tzinfo=dt.timezone.utc,
|
|
29575
|
+
) + dt.timedelta(days=1)
|
|
29576
|
+
top_projects = _share_top_projects_for_range(range_start, range_end)
|
|
29577
|
+
except (ValueError, KeyError):
|
|
29578
|
+
top_projects = []
|
|
29579
|
+
return {"days": days, "top_projects": top_projects}
|
|
29580
|
+
|
|
29581
|
+
|
|
29582
|
+
def _build_monthly_share_panel_data(options: dict,
|
|
29583
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29584
|
+
"""Monthly panel_data — last 12 months + top projects.
|
|
29585
|
+
|
|
29586
|
+
Reuses `DataSnapshot.monthly_periods` (MonthlyPeriodRow list).
|
|
29587
|
+
`used_pct` isn't stored on MonthlyPeriodRow (monthly aggregates
|
|
29588
|
+
don't carry a subscription-quota %), so it surfaces as 0.0.
|
|
29589
|
+
"""
|
|
29590
|
+
rows = list(getattr(snap, "monthly_periods", None) or []) if snap else []
|
|
29591
|
+
# monthly_periods is newest-first (see _dashboard_build_monthly_periods).
|
|
29592
|
+
# Reverse to oldest→newest — the Recap template reads months[0] as the
|
|
29593
|
+
# period-start anchor and months[-1] as the most recent month.
|
|
29594
|
+
rows = list(reversed(rows))
|
|
29595
|
+
months: list[dict] = []
|
|
29596
|
+
for r in rows:
|
|
29597
|
+
models = getattr(r, "models", None) or []
|
|
29598
|
+
top_model = (models[0].get("model") if models else None) or "—"
|
|
29599
|
+
months.append({
|
|
29600
|
+
"month": getattr(r, "label", "") or "", # "YYYY-MM"
|
|
29601
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
29602
|
+
"pct_used": 0.0,
|
|
29603
|
+
"top_model": top_model,
|
|
29604
|
+
})
|
|
29605
|
+
# Monthly top_projects: aggregate across the entire 12-month window.
|
|
29606
|
+
# Range = [first day of oldest month, last day of newest month + 1].
|
|
29607
|
+
top_projects: list[tuple[str, float]] = []
|
|
29608
|
+
if months:
|
|
29609
|
+
try:
|
|
29610
|
+
oldest_year, oldest_month = months[0]["month"].split("-")
|
|
29611
|
+
newest_year, newest_month = months[-1]["month"].split("-")
|
|
29612
|
+
range_start = dt.datetime(
|
|
29613
|
+
int(oldest_year), int(oldest_month), 1,
|
|
29614
|
+
tzinfo=dt.timezone.utc,
|
|
29615
|
+
)
|
|
29616
|
+
# End-exclusive: first day of the month AFTER the newest one.
|
|
29617
|
+
ny, nm = int(newest_year), int(newest_month) + 1
|
|
29618
|
+
if nm == 13:
|
|
29619
|
+
ny += 1
|
|
29620
|
+
nm = 1
|
|
29621
|
+
range_end = dt.datetime(ny, nm, 1, tzinfo=dt.timezone.utc)
|
|
29622
|
+
top_projects = _share_top_projects_for_range(range_start, range_end)
|
|
29623
|
+
except (ValueError, KeyError):
|
|
29624
|
+
top_projects = []
|
|
29625
|
+
return {"months": months, "top_projects": top_projects}
|
|
29626
|
+
|
|
29627
|
+
|
|
29628
|
+
def _build_forecast_share_panel_data(options: dict,
|
|
29629
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29630
|
+
"""Forecast panel_data — projection + per-day budgets + days-to-ceiling.
|
|
29631
|
+
|
|
29632
|
+
Reuses `DataSnapshot.forecast` (ForecastOutput).
|
|
29633
|
+
`projection_curve` is synthesized from `r_avg` / `r_recent` /
|
|
29634
|
+
`inputs.p_now` — the same arithmetic `snapshot_to_envelope` does for
|
|
29635
|
+
`week_avg_projection_pct` / `recent_24h_projection_pct`, extended
|
|
29636
|
+
across the next 7 days.
|
|
29637
|
+
"""
|
|
29638
|
+
fc = getattr(snap, "forecast", None) if snap else None
|
|
29639
|
+
if fc is None:
|
|
29640
|
+
return {
|
|
29641
|
+
"projected_end_pct": 0.0,
|
|
29642
|
+
"days_to_100pct": 0.0,
|
|
29643
|
+
"days_to_90pct": 0.0,
|
|
29644
|
+
"daily_budgets": {
|
|
29645
|
+
"avg": 0.0, "recent_24h": 0.0,
|
|
29646
|
+
"until_90pct": 0.0, "until_100pct": 0.0,
|
|
29647
|
+
},
|
|
29648
|
+
"projection_curve": [],
|
|
29649
|
+
"confidence": "LOW CONF",
|
|
29650
|
+
}
|
|
29651
|
+
inputs = getattr(fc, "inputs", None)
|
|
29652
|
+
p_now = float(getattr(inputs, "p_now", 0.0) or 0.0) if inputs else 0.0
|
|
29653
|
+
remaining_hours = float(
|
|
29654
|
+
getattr(inputs, "remaining_hours", 0.0) or 0.0
|
|
29655
|
+
) if inputs else 0.0
|
|
29656
|
+
confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
|
|
29657
|
+
r_avg = float(getattr(fc, "r_avg", 0.0) or 0.0)
|
|
29658
|
+
r_recent_raw = getattr(fc, "r_recent", None)
|
|
29659
|
+
r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
|
|
29660
|
+
# End-of-week projected %
|
|
29661
|
+
projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
|
|
29662
|
+
# Days to ceilings (simple inverse: hours-to-target / 24)
|
|
29663
|
+
def _days_to_ceiling(target_pct: float) -> float:
|
|
29664
|
+
if r_avg <= 0 or p_now >= target_pct:
|
|
29665
|
+
return 0.0
|
|
29666
|
+
hours = (target_pct - p_now) / r_avg
|
|
29667
|
+
return max(0.0, hours / 24.0)
|
|
29668
|
+
days_to_100 = _days_to_ceiling(100.0)
|
|
29669
|
+
days_to_90 = _days_to_ceiling(90.0)
|
|
29670
|
+
# Daily budgets — pull from fc.budgets[] when present
|
|
29671
|
+
budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
|
|
29672
|
+
"until_90pct": 0.0, "until_100pct": 0.0}
|
|
29673
|
+
for b in getattr(fc, "budgets", None) or []:
|
|
29674
|
+
tp = getattr(b, "target_percent", None)
|
|
29675
|
+
dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
|
|
29676
|
+
if tp == 100:
|
|
29677
|
+
budgets["until_100pct"] = dpd
|
|
29678
|
+
elif tp == 90:
|
|
29679
|
+
budgets["until_90pct"] = dpd
|
|
29680
|
+
# avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
|
|
29681
|
+
dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
|
|
29682
|
+
budgets["avg"] = dpp * r_avg * 24.0
|
|
29683
|
+
budgets["recent_24h"] = dpp * r_recent * 24.0
|
|
29684
|
+
# Projection curve — 7-day forward, using r_avg
|
|
29685
|
+
today = _share_now_utc().date()
|
|
29686
|
+
projection_curve: list[dict] = []
|
|
29687
|
+
for i in range(7):
|
|
29688
|
+
d = today + dt.timedelta(days=i)
|
|
29689
|
+
pct = (p_now + r_avg * (i * 24.0)) / 100.0
|
|
29690
|
+
projection_curve.append({
|
|
29691
|
+
"date": d.isoformat(),
|
|
29692
|
+
"projected_pct_used": pct,
|
|
29693
|
+
})
|
|
29694
|
+
return {
|
|
29695
|
+
"projected_end_pct": projected_end_pct,
|
|
29696
|
+
"days_to_100pct": days_to_100,
|
|
29697
|
+
"days_to_90pct": days_to_90,
|
|
29698
|
+
"daily_budgets": budgets,
|
|
29699
|
+
"projection_curve": projection_curve,
|
|
29700
|
+
"confidence": confidence,
|
|
29701
|
+
}
|
|
29702
|
+
|
|
29703
|
+
|
|
29704
|
+
def _build_blocks_share_panel_data(options: dict,
|
|
29705
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29706
|
+
"""Blocks panel_data — current 5h block KPI + 8 recent blocks + top projects.
|
|
29707
|
+
|
|
29708
|
+
Reuses `DataSnapshot.blocks_panel` (BlocksPanelRow list). Current
|
|
29709
|
+
block is the row with `is_active=True`; recent_blocks are the last 8.
|
|
29710
|
+
"""
|
|
29711
|
+
rows = list(getattr(snap, "blocks_panel", None) or []) if snap else []
|
|
29712
|
+
current = next((r for r in rows if getattr(r, "is_active", False)), None)
|
|
29713
|
+
cb: dict = {}
|
|
29714
|
+
if current is not None:
|
|
29715
|
+
cb = {
|
|
29716
|
+
"start_at": _share_iso(getattr(current, "start_at", None)) or "",
|
|
29717
|
+
"end_at": _share_iso(getattr(current, "end_at", None)) or "",
|
|
29718
|
+
"cost_usd": float(getattr(current, "cost_usd", 0.0) or 0.0),
|
|
29719
|
+
"pct_used": 0.0, # BlocksPanelRow doesn't carry a %
|
|
29720
|
+
"tokens_total": 0, # BlocksPanelRow drops token counts
|
|
29721
|
+
}
|
|
29722
|
+
# blocks_panel is newest-first (see _dashboard_build_blocks_panel:
|
|
29723
|
+
# `rows.sort(key=lambda r: r.start_at, reverse=True)`). Take the most
|
|
29724
|
+
# recent 8 blocks and reverse to oldest→newest so the template's chart
|
|
29725
|
+
# (uses enumerate(recent) for x-position) plots left→right time order.
|
|
29726
|
+
recent: list[dict] = []
|
|
29727
|
+
for r in list(reversed(rows[:8])):
|
|
29728
|
+
recent.append({
|
|
29729
|
+
"start_at": _share_iso(getattr(r, "start_at", None)) or "",
|
|
29730
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
29731
|
+
})
|
|
29732
|
+
# Blocks top_projects: aggregate across the window covered by
|
|
29733
|
+
# `recent_blocks` (the oldest block's start through the most recent
|
|
29734
|
+
# block's end — also the active block, if any). Mirrors what the
|
|
29735
|
+
# panel actually shows the user.
|
|
29736
|
+
top_projects: list[tuple[str, float]] = []
|
|
29737
|
+
if recent:
|
|
29738
|
+
try:
|
|
29739
|
+
range_start = parse_iso_datetime(
|
|
29740
|
+
recent[0]["start_at"], "blocks.recent_blocks[0].start_at",
|
|
29741
|
+
)
|
|
29742
|
+
# Pick the end of the latest block. `recent` is oldest→newest
|
|
29743
|
+
# after the slice/reverse, so `recent[-1]` is the most recent.
|
|
29744
|
+
# Each block is 5 hours long; if `current_block` has an
|
|
29745
|
+
# explicit `end_at`, prefer that since it may be the active
|
|
29746
|
+
# block whose end_at lives in the future.
|
|
29747
|
+
if cb.get("end_at"):
|
|
29748
|
+
range_end = parse_iso_datetime(
|
|
29749
|
+
cb["end_at"], "blocks.current_block.end_at",
|
|
29750
|
+
)
|
|
29751
|
+
else:
|
|
29752
|
+
range_end = parse_iso_datetime(
|
|
29753
|
+
recent[-1]["start_at"], "blocks.recent_blocks[-1].start_at",
|
|
29754
|
+
) + dt.timedelta(hours=5)
|
|
29755
|
+
top_projects = _share_top_projects_for_range(range_start, range_end)
|
|
29756
|
+
except (ValueError, KeyError):
|
|
29757
|
+
top_projects = []
|
|
29758
|
+
return {
|
|
29759
|
+
"current_block": cb,
|
|
29760
|
+
"recent_blocks": recent,
|
|
29761
|
+
"top_projects": top_projects,
|
|
29762
|
+
}
|
|
29763
|
+
|
|
29764
|
+
|
|
29765
|
+
def _build_sessions_share_panel_data(options: dict,
|
|
29766
|
+
snap: "DataSnapshot | None") -> dict:
|
|
29767
|
+
"""Sessions panel_data — top N sessions table.
|
|
29768
|
+
|
|
29769
|
+
Reuses `DataSnapshot.sessions` (TuiSessionRow list). Truncated to
|
|
29770
|
+
`options.top_n` (default 15) by upstream cap before the Recap builder
|
|
29771
|
+
runs its own slice.
|
|
29772
|
+
"""
|
|
29773
|
+
rows = list(getattr(snap, "sessions", None) or []) if snap else []
|
|
29774
|
+
top_n = options.get("top_n", 15)
|
|
29775
|
+
try:
|
|
29776
|
+
top_n_int = max(1, int(top_n))
|
|
29777
|
+
except (TypeError, ValueError):
|
|
29778
|
+
top_n_int = 15
|
|
29779
|
+
sessions: list[dict] = []
|
|
29780
|
+
for r in rows[:top_n_int]:
|
|
29781
|
+
sessions.append({
|
|
29782
|
+
"session_id": getattr(r, "session_id", "") or "",
|
|
29783
|
+
"project_path": getattr(r, "project_label", "") or "",
|
|
29784
|
+
"cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
|
|
29785
|
+
"started_at": _share_iso(getattr(r, "started_at", None)) or "",
|
|
29786
|
+
"model": getattr(r, "model_primary", "") or "",
|
|
29787
|
+
})
|
|
29788
|
+
return {"sessions": sessions}
|
|
29789
|
+
|
|
29790
|
+
|
|
28919
29791
|
def _share_render_and_emit(snap, args) -> None:
|
|
28920
29792
|
"""End-to-end: scrub -> render -> emit -> optional open.
|
|
28921
29793
|
|
|
@@ -32215,6 +33087,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
32215
33087
|
self._handle_get_update_status()
|
|
32216
33088
|
elif path.startswith("/api/update/stream/"):
|
|
32217
33089
|
self._handle_get_update_stream(path)
|
|
33090
|
+
elif path == "/api/share/templates":
|
|
33091
|
+
self._handle_share_templates_get()
|
|
33092
|
+
elif path == "/api/share/presets":
|
|
33093
|
+
self._handle_share_presets_get()
|
|
33094
|
+
elif path == "/api/share/history":
|
|
33095
|
+
self._handle_share_history_get()
|
|
32218
33096
|
else:
|
|
32219
33097
|
self.send_error(404, "not found")
|
|
32220
33098
|
|
|
@@ -32230,6 +33108,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
32230
33108
|
self._handle_post_update()
|
|
32231
33109
|
elif path == "/api/update/dismiss":
|
|
32232
33110
|
self._handle_post_update_dismiss()
|
|
33111
|
+
elif path == "/api/share/render":
|
|
33112
|
+
self._handle_share_render_post()
|
|
33113
|
+
elif path == "/api/share/compose":
|
|
33114
|
+
self._handle_share_compose_post()
|
|
33115
|
+
elif path == "/api/share/presets":
|
|
33116
|
+
self._handle_share_presets_post()
|
|
33117
|
+
elif path == "/api/share/history":
|
|
33118
|
+
self._handle_share_history_post()
|
|
32233
33119
|
else:
|
|
32234
33120
|
self.send_error(404, "not found")
|
|
32235
33121
|
|
|
@@ -32247,6 +33133,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
32247
33133
|
def do_DELETE(self) -> None: # noqa: N802 — stdlib API
|
|
32248
33134
|
if self._method_not_allowed_for_settings():
|
|
32249
33135
|
return
|
|
33136
|
+
path = self.path.split("?", 1)[0]
|
|
33137
|
+
if path.startswith("/api/share/presets/"):
|
|
33138
|
+
self._handle_share_presets_delete()
|
|
33139
|
+
return
|
|
33140
|
+
if path == "/api/share/history":
|
|
33141
|
+
self._handle_share_history_delete()
|
|
33142
|
+
return
|
|
32250
33143
|
self.send_error(501, "Unsupported method ('DELETE')")
|
|
32251
33144
|
|
|
32252
33145
|
def do_PATCH(self) -> None: # noqa: N802 — stdlib API
|
|
@@ -32771,6 +33664,766 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
32771
33664
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
32772
33665
|
self._respond_json(200, {"alert": payload, "dispatch": status})
|
|
32773
33666
|
|
|
33667
|
+
# ---- share endpoints (spec §5.1) ----------------------------------
|
|
33668
|
+
#
|
|
33669
|
+
# GET /api/share/templates?panel=<id> → list Recap/Visual/Detail
|
|
33670
|
+
# templates registered in _lib_share_templates for that panel.
|
|
33671
|
+
# POST /api/share/render → render one panel-section to
|
|
33672
|
+
# body via the kernel; returns {body, content_type, snapshot}
|
|
33673
|
+
# with kernel_version + data_digest for v2 composer drift checks.
|
|
33674
|
+
#
|
|
33675
|
+
# The template registry is late-imported per-request to keep dashboard
|
|
33676
|
+
# startup cheap — matches cmd_tui's `rich` lazy-import pattern. Same
|
|
33677
|
+
# late-load applies to the kernel (`_lib_share`) via `_share_load_lib`.
|
|
33678
|
+
# GET is unauthenticated (idempotent read). POST gates on
|
|
33679
|
+
# `_check_origin_csrf` (same convention as /api/sync, /api/settings).
|
|
33680
|
+
|
|
33681
|
+
def _share_load_templates_module(self):
|
|
33682
|
+
"""Late-load the share-templates registry, cached in sys.modules.
|
|
33683
|
+
|
|
33684
|
+
Keeps dashboard startup zero-cost — the registry only imports when
|
|
33685
|
+
the first share request arrives. Subsequent requests reuse the
|
|
33686
|
+
sys.modules entry; matches the `_share_load_lib` convention so
|
|
33687
|
+
ShareTemplate identity stays stable across calls.
|
|
33688
|
+
"""
|
|
33689
|
+
cached = sys.modules.get("_lib_share_templates")
|
|
33690
|
+
if cached is not None:
|
|
33691
|
+
return cached
|
|
33692
|
+
import importlib.util as _ilu
|
|
33693
|
+
p = pathlib.Path(__file__).resolve().parent / "_lib_share_templates.py"
|
|
33694
|
+
spec = _ilu.spec_from_file_location("_lib_share_templates", p)
|
|
33695
|
+
mod = _ilu.module_from_spec(spec)
|
|
33696
|
+
sys.modules["_lib_share_templates"] = mod
|
|
33697
|
+
try:
|
|
33698
|
+
spec.loader.exec_module(mod)
|
|
33699
|
+
except Exception:
|
|
33700
|
+
sys.modules.pop("_lib_share_templates", None)
|
|
33701
|
+
raise
|
|
33702
|
+
return mod
|
|
33703
|
+
|
|
33704
|
+
def _handle_share_templates_get(self) -> None:
|
|
33705
|
+
"""List share templates registered for the requested panel.
|
|
33706
|
+
|
|
33707
|
+
Query: ?panel=<id>. Rejects missing or non-share-capable panels
|
|
33708
|
+
(e.g., `alerts`) with 400 + {error, field} envelope (matches
|
|
33709
|
+
existing dashboard error shape; see spec §5.5).
|
|
33710
|
+
"""
|
|
33711
|
+
import urllib.parse as _urlparse
|
|
33712
|
+
qs = _urlparse.urlparse(self.path).query
|
|
33713
|
+
params = _urlparse.parse_qs(qs)
|
|
33714
|
+
panel = (params.get("panel", [""])[0] or "").strip()
|
|
33715
|
+
if not panel:
|
|
33716
|
+
self._respond_json(400, {
|
|
33717
|
+
"error": "missing query param: panel",
|
|
33718
|
+
"field": "panel",
|
|
33719
|
+
})
|
|
33720
|
+
return
|
|
33721
|
+
tpl_mod = self._share_load_templates_module()
|
|
33722
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
33723
|
+
self._respond_json(400, {
|
|
33724
|
+
"error": f"unknown share panel: {panel!r}",
|
|
33725
|
+
"field": "panel",
|
|
33726
|
+
})
|
|
33727
|
+
return
|
|
33728
|
+
templates = [
|
|
33729
|
+
{
|
|
33730
|
+
"id": t.id,
|
|
33731
|
+
"label": t.label,
|
|
33732
|
+
"description": t.description,
|
|
33733
|
+
"default_options": dict(t.default_options),
|
|
33734
|
+
}
|
|
33735
|
+
for t in tpl_mod.templates_for_panel(panel)
|
|
33736
|
+
]
|
|
33737
|
+
self._respond_json(200, {"panel": panel, "templates": templates})
|
|
33738
|
+
|
|
33739
|
+
def _handle_share_render_post(self) -> None:
|
|
33740
|
+
"""Render a panel-section to body via the share kernel.
|
|
33741
|
+
|
|
33742
|
+
Body shape: ``{panel, template_id, options}``. Validates panel +
|
|
33743
|
+
template_id against the registry, dispatches to the per-panel
|
|
33744
|
+
`_build_<panel>_share_panel_data` helper to assemble the
|
|
33745
|
+
builder-shaped dict from the current dashboard snapshot, runs the
|
|
33746
|
+
template's builder, applies `_scrub` when
|
|
33747
|
+
``options.reveal_projects`` is False, then renders via
|
|
33748
|
+
`_lib_share.render`. Response: ``{body, content_type, snapshot}``
|
|
33749
|
+
where `snapshot` carries `kernel_version` + `data_digest` for the
|
|
33750
|
+
v2 composer's drift detection (spec §5.2).
|
|
33751
|
+
|
|
33752
|
+
CSRF: Origin/Host parity via `_check_origin_csrf` — same gate as
|
|
33753
|
+
`/api/sync`, `/api/settings`, `/api/alerts/test`.
|
|
33754
|
+
"""
|
|
33755
|
+
if not self._check_origin_csrf():
|
|
33756
|
+
return
|
|
33757
|
+
try:
|
|
33758
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
33759
|
+
except ValueError:
|
|
33760
|
+
length = 0
|
|
33761
|
+
try:
|
|
33762
|
+
raw = self.rfile.read(length) if length > 0 else b""
|
|
33763
|
+
req = json.loads(raw) if raw else {}
|
|
33764
|
+
except (ValueError, json.JSONDecodeError):
|
|
33765
|
+
self._respond_json(400, {"error": "malformed json"})
|
|
33766
|
+
return
|
|
33767
|
+
if not isinstance(req, dict):
|
|
33768
|
+
self._respond_json(400, {"error": "expected JSON object"})
|
|
33769
|
+
return
|
|
33770
|
+
panel = req.get("panel")
|
|
33771
|
+
template_id = req.get("template_id")
|
|
33772
|
+
options = req.get("options") or {}
|
|
33773
|
+
if not isinstance(options, dict):
|
|
33774
|
+
self._respond_json(400, {
|
|
33775
|
+
"error": "options must be an object",
|
|
33776
|
+
"field": "options",
|
|
33777
|
+
})
|
|
33778
|
+
return
|
|
33779
|
+
if not isinstance(panel, str) or not panel:
|
|
33780
|
+
self._respond_json(400, {
|
|
33781
|
+
"error": "missing or non-string panel",
|
|
33782
|
+
"field": "panel",
|
|
33783
|
+
})
|
|
33784
|
+
return
|
|
33785
|
+
if not isinstance(template_id, str) or not template_id:
|
|
33786
|
+
self._respond_json(400, {
|
|
33787
|
+
"error": "missing or non-string template_id",
|
|
33788
|
+
"field": "template_id",
|
|
33789
|
+
})
|
|
33790
|
+
return
|
|
33791
|
+
fmt = options.get("format", "html")
|
|
33792
|
+
if fmt not in ("md", "html", "svg"):
|
|
33793
|
+
self._respond_json(400, {
|
|
33794
|
+
"error": f"unknown format: {fmt!r}",
|
|
33795
|
+
"field": "options.format",
|
|
33796
|
+
})
|
|
33797
|
+
return
|
|
33798
|
+
theme = options.get("theme", "light")
|
|
33799
|
+
if theme not in ("light", "dark"):
|
|
33800
|
+
self._respond_json(400, {
|
|
33801
|
+
"error": f"unknown theme: {theme!r}",
|
|
33802
|
+
"field": "options.theme",
|
|
33803
|
+
})
|
|
33804
|
+
return
|
|
33805
|
+
# `top_n` may be explicit-null when the UI's Top-N input is
|
|
33806
|
+
# cleared (Knobs.tsx:43); treat null as "use template default"
|
|
33807
|
+
# rather than 400-ing every preview/export until the user types
|
|
33808
|
+
# a number.
|
|
33809
|
+
if options.get("top_n") is not None:
|
|
33810
|
+
top_n_raw = options["top_n"]
|
|
33811
|
+
if not isinstance(top_n_raw, int) or isinstance(top_n_raw, bool) or top_n_raw < 1:
|
|
33812
|
+
self._respond_json(400, {
|
|
33813
|
+
"error": f"top_n must be a positive integer, got {top_n_raw!r}",
|
|
33814
|
+
"field": "options.top_n",
|
|
33815
|
+
})
|
|
33816
|
+
return
|
|
33817
|
+
|
|
33818
|
+
tpl_mod = self._share_load_templates_module()
|
|
33819
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
33820
|
+
self._respond_json(400, {
|
|
33821
|
+
"error": f"unknown share panel: {panel!r}",
|
|
33822
|
+
"field": "panel",
|
|
33823
|
+
})
|
|
33824
|
+
return
|
|
33825
|
+
try:
|
|
33826
|
+
template = tpl_mod.get_template(template_id)
|
|
33827
|
+
except KeyError:
|
|
33828
|
+
self._respond_json(400, {
|
|
33829
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
33830
|
+
"field": "template_id",
|
|
33831
|
+
})
|
|
33832
|
+
return
|
|
33833
|
+
if template.panel != panel:
|
|
33834
|
+
self._respond_json(400, {
|
|
33835
|
+
"error": (
|
|
33836
|
+
f"template_id {template_id!r} belongs to panel "
|
|
33837
|
+
f"{template.panel!r}, not {panel!r}"
|
|
33838
|
+
),
|
|
33839
|
+
"field": "template_id",
|
|
33840
|
+
})
|
|
33841
|
+
return
|
|
33842
|
+
|
|
33843
|
+
# Build panel_data from the live dashboard snapshot — reuses the
|
|
33844
|
+
# already-built `DataSnapshot` so we don't re-query the DB on the
|
|
33845
|
+
# share hot path. `_build_share_panel_data` dispatches per panel.
|
|
33846
|
+
snap_ref = type(self).snapshot_ref
|
|
33847
|
+
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
33848
|
+
# Period override (current / previous / custom). For
|
|
33849
|
+
# `kind='current'` (the default) this is a no-op; otherwise we
|
|
33850
|
+
# re-build the relevant panel's DataSnapshot field from DB with
|
|
33851
|
+
# a shifted `now_utc` before slicing.
|
|
33852
|
+
data_snap, period_err = _share_apply_period_override(panel, options,
|
|
33853
|
+
data_snap)
|
|
33854
|
+
if period_err is not None:
|
|
33855
|
+
self._respond_json(400, period_err)
|
|
33856
|
+
return
|
|
33857
|
+
try:
|
|
33858
|
+
panel_data = _build_share_panel_data(panel, options, data_snap)
|
|
33859
|
+
except Exception as exc:
|
|
33860
|
+
self._respond_json(500, {"error": f"panel_data build failed: {exc}"})
|
|
33861
|
+
return
|
|
33862
|
+
|
|
33863
|
+
# Run template builder → kernel render. Builder produces a
|
|
33864
|
+
# ShareSnapshot; `_scrub` anonymizes project labels when the
|
|
33865
|
+
# client opted in to anon-on-export (`reveal_projects=False`).
|
|
33866
|
+
ls = _share_load_lib()
|
|
33867
|
+
try:
|
|
33868
|
+
snap_built = template.builder(panel_data=panel_data, options=options)
|
|
33869
|
+
except Exception as exc:
|
|
33870
|
+
self._respond_json(500, {"error": f"builder failed: {exc}"})
|
|
33871
|
+
return
|
|
33872
|
+
snap_built = replace(snap_built, template_id=template_id)
|
|
33873
|
+
# Content toggles (spec §Q4). Defaults match the existing
|
|
33874
|
+
# behavior (chart on, table on); explicit False strips the
|
|
33875
|
+
# corresponding section from the ShareSnapshot. ShareSnapshot
|
|
33876
|
+
# is frozen so we use dataclasses.replace.
|
|
33877
|
+
snap_built = _share_apply_content_toggles(snap_built, options)
|
|
33878
|
+
reveal = bool(options.get("reveal_projects", True))
|
|
33879
|
+
if not reveal:
|
|
33880
|
+
snap_built = ls._scrub(snap_built, reveal_projects=False)
|
|
33881
|
+
try:
|
|
33882
|
+
body = ls.render(
|
|
33883
|
+
snap_built,
|
|
33884
|
+
format=fmt,
|
|
33885
|
+
theme=options.get("theme", "light"),
|
|
33886
|
+
branding=not options.get("no_branding", False),
|
|
33887
|
+
)
|
|
33888
|
+
except Exception as exc:
|
|
33889
|
+
self._respond_json(500, {"error": f"render failed: {exc}"})
|
|
33890
|
+
return
|
|
33891
|
+
content_type = {
|
|
33892
|
+
"md": "text/markdown",
|
|
33893
|
+
"html": "text/html",
|
|
33894
|
+
"svg": "image/svg+xml",
|
|
33895
|
+
}[fmt]
|
|
33896
|
+
|
|
33897
|
+
# data_digest hashes the inputs that identify the underlying DATA
|
|
33898
|
+
# (panel + template + panel_data), NOT rendering toggles like theme
|
|
33899
|
+
# / branding / reveal_projects / format. Used by the composer to
|
|
33900
|
+
# detect "section data has drifted since add-time" (spec §5.2 /
|
|
33901
|
+
# §7.1) — flipping anon-on-export must not register as drift, since
|
|
33902
|
+
# the underlying data is identical.
|
|
33903
|
+
digest_input = {
|
|
33904
|
+
"panel": panel,
|
|
33905
|
+
"template_id": template_id,
|
|
33906
|
+
"panel_data": panel_data,
|
|
33907
|
+
}
|
|
33908
|
+
try:
|
|
33909
|
+
data_digest = ls._data_digest(digest_input)
|
|
33910
|
+
except Exception:
|
|
33911
|
+
# Defensive: digest is non-blocking for the response — fall
|
|
33912
|
+
# back to an empty string and let the composer treat it as
|
|
33913
|
+
# "always drifted" rather than failing the whole render.
|
|
33914
|
+
data_digest = ""
|
|
33915
|
+
|
|
33916
|
+
self._respond_json(200, {
|
|
33917
|
+
"body": body,
|
|
33918
|
+
"content_type": content_type,
|
|
33919
|
+
"snapshot": {
|
|
33920
|
+
"kernel_version": ls.KERNEL_VERSION,
|
|
33921
|
+
"panel": panel,
|
|
33922
|
+
"template_id": template_id,
|
|
33923
|
+
"options": options,
|
|
33924
|
+
"generated_at": _share_now_utc_iso(),
|
|
33925
|
+
"data_digest": data_digest,
|
|
33926
|
+
},
|
|
33927
|
+
})
|
|
33928
|
+
|
|
33929
|
+
# ---- /api/share/compose — stitch many basket sections (spec §5.3) ----
|
|
33930
|
+
|
|
33931
|
+
def _handle_share_compose_post(self) -> None:
|
|
33932
|
+
"""Stitch multiple panel sections into one composed document.
|
|
33933
|
+
|
|
33934
|
+
Recipe-only. The server re-renders every section from its
|
|
33935
|
+
``(panel, template_id, options)`` recipe — never accepting a client-
|
|
33936
|
+
supplied ``body``. Per-section drift detection compares the fresh
|
|
33937
|
+
``data_digest`` against the client's ``data_digest_at_add``;
|
|
33938
|
+
mismatches surface as ``section_results[i].drift_detected = true``
|
|
33939
|
+
for the composer's "Outdated" badge.
|
|
33940
|
+
|
|
33941
|
+
Spec §5.3, §10.3. CSRF-gated.
|
|
33942
|
+
"""
|
|
33943
|
+
if not self._check_origin_csrf():
|
|
33944
|
+
return
|
|
33945
|
+
try:
|
|
33946
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
33947
|
+
except ValueError:
|
|
33948
|
+
length = 0
|
|
33949
|
+
try:
|
|
33950
|
+
raw = self.rfile.read(length) if length > 0 else b""
|
|
33951
|
+
req = json.loads(raw) if raw else {}
|
|
33952
|
+
except (ValueError, json.JSONDecodeError):
|
|
33953
|
+
self._respond_json(400, {"error": "malformed json"})
|
|
33954
|
+
return
|
|
33955
|
+
if not isinstance(req, dict):
|
|
33956
|
+
self._respond_json(400, {"error": "expected JSON object"})
|
|
33957
|
+
return
|
|
33958
|
+
|
|
33959
|
+
title = req.get("title")
|
|
33960
|
+
theme = req.get("theme", "light")
|
|
33961
|
+
fmt = req.get("format", "html")
|
|
33962
|
+
no_branding = bool(req.get("no_branding", False))
|
|
33963
|
+
reveal_projects = bool(req.get("reveal_projects", False))
|
|
33964
|
+
sections_in = req.get("sections")
|
|
33965
|
+
if not isinstance(title, str) or not title:
|
|
33966
|
+
self._respond_json(400, {"error": "missing title", "field": "title"})
|
|
33967
|
+
return
|
|
33968
|
+
if theme not in ("light", "dark"):
|
|
33969
|
+
self._respond_json(400, {"error": f"unknown theme: {theme!r}",
|
|
33970
|
+
"field": "theme"})
|
|
33971
|
+
return
|
|
33972
|
+
if fmt not in ("md", "html", "svg"):
|
|
33973
|
+
self._respond_json(400, {"error": f"unknown format: {fmt!r}",
|
|
33974
|
+
"field": "format"})
|
|
33975
|
+
return
|
|
33976
|
+
if not isinstance(sections_in, list) or not sections_in:
|
|
33977
|
+
self._respond_json(400, {
|
|
33978
|
+
"error": "sections must be a non-empty array",
|
|
33979
|
+
"field": "sections",
|
|
33980
|
+
})
|
|
33981
|
+
return
|
|
33982
|
+
|
|
33983
|
+
tpl_mod = self._share_load_templates_module()
|
|
33984
|
+
ls = _share_load_lib()
|
|
33985
|
+
snap_ref = type(self).snapshot_ref
|
|
33986
|
+
data_snap = snap_ref.get() if snap_ref is not None else None
|
|
33987
|
+
|
|
33988
|
+
composed_sections: list = []
|
|
33989
|
+
section_results: list[dict] = []
|
|
33990
|
+
|
|
33991
|
+
for idx, sec in enumerate(sections_in):
|
|
33992
|
+
if not isinstance(sec, dict):
|
|
33993
|
+
self._respond_json(400, {
|
|
33994
|
+
"error": f"sections[{idx}] must be an object",
|
|
33995
|
+
"field": f"sections[{idx}]",
|
|
33996
|
+
})
|
|
33997
|
+
return
|
|
33998
|
+
# Explicit: client-supplied `body` and `content_type` are
|
|
33999
|
+
# silently IGNORED. This is the privacy chokepoint — the
|
|
34000
|
+
# regression test in tests/test_api_share.py guards it.
|
|
34001
|
+
snap_recipe = sec.get("snapshot") or {}
|
|
34002
|
+
panel = snap_recipe.get("panel")
|
|
34003
|
+
template_id = snap_recipe.get("template_id")
|
|
34004
|
+
sec_opts = snap_recipe.get("options") or {}
|
|
34005
|
+
digest_at_add = snap_recipe.get("data_digest_at_add") or ""
|
|
34006
|
+
if (not isinstance(panel, str)
|
|
34007
|
+
or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
|
|
34008
|
+
self._respond_json(400, {
|
|
34009
|
+
"error": (
|
|
34010
|
+
f"sections[{idx}].snapshot.panel invalid: {panel!r}"
|
|
34011
|
+
),
|
|
34012
|
+
"field": f"sections[{idx}].snapshot.panel",
|
|
34013
|
+
})
|
|
34014
|
+
return
|
|
34015
|
+
try:
|
|
34016
|
+
template = tpl_mod.get_template(template_id)
|
|
34017
|
+
except KeyError:
|
|
34018
|
+
self._respond_json(400, {
|
|
34019
|
+
"error": (
|
|
34020
|
+
f"sections[{idx}].snapshot.template_id "
|
|
34021
|
+
f"unknown: {template_id!r}"
|
|
34022
|
+
),
|
|
34023
|
+
"field": f"sections[{idx}].snapshot.template_id",
|
|
34024
|
+
})
|
|
34025
|
+
return
|
|
34026
|
+
if template.panel != panel:
|
|
34027
|
+
self._respond_json(400, {
|
|
34028
|
+
"error": (f"sections[{idx}].snapshot.template_id "
|
|
34029
|
+
f"{template_id!r} belongs to panel "
|
|
34030
|
+
f"{template.panel!r}, not {panel!r}"),
|
|
34031
|
+
"field": f"sections[{idx}].snapshot.template_id",
|
|
34032
|
+
})
|
|
34033
|
+
return
|
|
34034
|
+
|
|
34035
|
+
# Force the composite reveal_projects across every section
|
|
34036
|
+
# (spec §8.5: per-section anon at add-time is ignored at compose).
|
|
34037
|
+
composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
|
|
34038
|
+
"theme": theme, "format": fmt,
|
|
34039
|
+
"no_branding": no_branding}
|
|
34040
|
+
# Per-section period override — each basket item carries its
|
|
34041
|
+
# own period recipe, independent of the composite anon flag.
|
|
34042
|
+
sec_snap, period_err = _share_apply_period_override(
|
|
34043
|
+
panel, composite_opts, data_snap,
|
|
34044
|
+
)
|
|
34045
|
+
if period_err is not None:
|
|
34046
|
+
self._respond_json(400, {
|
|
34047
|
+
"error": f"sections[{idx}]: {period_err['error']}",
|
|
34048
|
+
"field": f"sections[{idx}].snapshot.{period_err['field']}",
|
|
34049
|
+
})
|
|
34050
|
+
return
|
|
34051
|
+
try:
|
|
34052
|
+
panel_data = _build_share_panel_data(panel, composite_opts,
|
|
34053
|
+
sec_snap)
|
|
34054
|
+
except Exception as exc:
|
|
34055
|
+
self._respond_json(500, {
|
|
34056
|
+
"error": f"sections[{idx}] panel_data build failed: {exc}",
|
|
34057
|
+
})
|
|
34058
|
+
return
|
|
34059
|
+
try:
|
|
34060
|
+
snap_built = template.builder(panel_data=panel_data,
|
|
34061
|
+
options=composite_opts)
|
|
34062
|
+
except Exception as exc:
|
|
34063
|
+
self._respond_json(500, {
|
|
34064
|
+
"error": f"sections[{idx}] builder failed: {exc}",
|
|
34065
|
+
})
|
|
34066
|
+
return
|
|
34067
|
+
snap_built = replace(snap_built, template_id=template_id)
|
|
34068
|
+
# Same content toggles as the single-section render path.
|
|
34069
|
+
# Per-section `show_chart`/`show_table` from the basket
|
|
34070
|
+
# recipe are applied here; the composite anon flag is
|
|
34071
|
+
# already merged into composite_opts upstream.
|
|
34072
|
+
snap_built = _share_apply_content_toggles(snap_built, composite_opts)
|
|
34073
|
+
if not reveal_projects:
|
|
34074
|
+
snap_built = ls._scrub(snap_built, reveal_projects=False)
|
|
34075
|
+
|
|
34076
|
+
# Defensive: digest is non-blocking metadata — fall back to
|
|
34077
|
+
# "" on failure rather than 500-ing the whole compose
|
|
34078
|
+
# (mirrors the render handler at bin/cctally:33402-33408).
|
|
34079
|
+
try:
|
|
34080
|
+
digest_now = ls._data_digest({
|
|
34081
|
+
"panel": panel,
|
|
34082
|
+
"template_id": template_id,
|
|
34083
|
+
"panel_data": panel_data,
|
|
34084
|
+
})
|
|
34085
|
+
except Exception:
|
|
34086
|
+
digest_now = ""
|
|
34087
|
+
composed_sections.append(ls.ComposedSection(
|
|
34088
|
+
snap=snap_built,
|
|
34089
|
+
drift_detected=(digest_now != digest_at_add),
|
|
34090
|
+
))
|
|
34091
|
+
section_results.append({
|
|
34092
|
+
"snapshot_id": f"{idx:02d}",
|
|
34093
|
+
"drift_detected": digest_now != digest_at_add,
|
|
34094
|
+
"data_digest_at_add": digest_at_add,
|
|
34095
|
+
"data_digest_now": digest_now,
|
|
34096
|
+
})
|
|
34097
|
+
|
|
34098
|
+
compose_opts = ls.ComposeOptions(
|
|
34099
|
+
title=title, theme=theme, format=fmt,
|
|
34100
|
+
no_branding=no_branding, reveal_projects=reveal_projects,
|
|
34101
|
+
)
|
|
34102
|
+
try:
|
|
34103
|
+
body = ls.compose(tuple(composed_sections), opts=compose_opts)
|
|
34104
|
+
except Exception as exc:
|
|
34105
|
+
self._respond_json(500, {"error": f"compose failed: {exc}"})
|
|
34106
|
+
return
|
|
34107
|
+
|
|
34108
|
+
content_type = {
|
|
34109
|
+
"md": "text/markdown",
|
|
34110
|
+
"html": "text/html",
|
|
34111
|
+
"svg": "image/svg+xml",
|
|
34112
|
+
}[fmt]
|
|
34113
|
+
self._respond_json(200, {
|
|
34114
|
+
"body": body,
|
|
34115
|
+
"content_type": content_type,
|
|
34116
|
+
"snapshot": {
|
|
34117
|
+
"kernel_version": ls.KERNEL_VERSION,
|
|
34118
|
+
"composed_at": _share_now_utc_iso(),
|
|
34119
|
+
"section_results": section_results,
|
|
34120
|
+
},
|
|
34121
|
+
})
|
|
34122
|
+
|
|
34123
|
+
# ---- /api/share/presets — saved-recipe CRUD (spec §5.1, §11.3) ----
|
|
34124
|
+
#
|
|
34125
|
+
# GET /api/share/presets → list, grouped by panel
|
|
34126
|
+
# POST /api/share/presets → upsert (panel, name)
|
|
34127
|
+
# DELETE /api/share/presets/{panel}/{name} → remove one preset
|
|
34128
|
+
#
|
|
34129
|
+
# Persistence: `config.json` under `share.presets[<panel>][<name>]` so
|
|
34130
|
+
# the CLI can read them later (CLI consumer is designed for, not
|
|
34131
|
+
# shipped — out of scope per spec §15). GET is unauthenticated like
|
|
34132
|
+
# `/api/share/templates`; POST + DELETE go through `_check_origin_csrf`
|
|
34133
|
+
# (same gate as `/api/sync`, `/api/settings`, `/api/alerts/test`).
|
|
34134
|
+
# Write discipline: `config_writer_lock` + `_load_config_unlocked` +
|
|
34135
|
+
# `save_config` (atomic `os.replace`). Never call `load_config` from
|
|
34136
|
+
# inside the writer lock — `fcntl.flock` is per-fd and would
|
|
34137
|
+
# self-deadlock; see `_cmd_config_set` for the established pattern.
|
|
34138
|
+
|
|
34139
|
+
def _handle_share_presets_get(self) -> None:
|
|
34140
|
+
"""List saved share presets, grouped by panel (spec §5.1, §11.3).
|
|
34141
|
+
|
|
34142
|
+
Read-only — no CSRF gate. `config.json` may not contain the
|
|
34143
|
+
`share.presets` key on first run; returns `{"presets": {}}` then.
|
|
34144
|
+
"""
|
|
34145
|
+
cfg = load_config()
|
|
34146
|
+
presets = (cfg.get("share") or {}).get("presets") or {}
|
|
34147
|
+
self._respond_json(200, {"presets": presets})
|
|
34148
|
+
|
|
34149
|
+
def _handle_share_presets_post(self) -> None:
|
|
34150
|
+
"""Create or overwrite a preset (idempotent on `(panel, name)`).
|
|
34151
|
+
|
|
34152
|
+
Body: ``{panel, name, template_id, options}``. CSRF-gated.
|
|
34153
|
+
|
|
34154
|
+
Persistence is a read-modify-write under ``config_writer_lock`` +
|
|
34155
|
+
``_load_config_unlocked``. The plain ``load_config`` would
|
|
34156
|
+
self-deadlock on the same fcntl.flock fd; see the CLAUDE.md
|
|
34157
|
+
config-write invariant and `_cmd_config_set` for the canonical
|
|
34158
|
+
pattern.
|
|
34159
|
+
"""
|
|
34160
|
+
if not self._check_origin_csrf():
|
|
34161
|
+
return
|
|
34162
|
+
try:
|
|
34163
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
34164
|
+
except ValueError:
|
|
34165
|
+
length = 0
|
|
34166
|
+
try:
|
|
34167
|
+
raw = self.rfile.read(length) if length > 0 else b""
|
|
34168
|
+
req = json.loads(raw) if raw else {}
|
|
34169
|
+
except (ValueError, json.JSONDecodeError):
|
|
34170
|
+
self._respond_json(400, {"error": "malformed json"})
|
|
34171
|
+
return
|
|
34172
|
+
if not isinstance(req, dict):
|
|
34173
|
+
self._respond_json(400, {"error": "expected JSON object"})
|
|
34174
|
+
return
|
|
34175
|
+
panel = req.get("panel")
|
|
34176
|
+
name = req.get("name")
|
|
34177
|
+
template_id = req.get("template_id")
|
|
34178
|
+
options = req.get("options")
|
|
34179
|
+
if not isinstance(panel, str) or not panel:
|
|
34180
|
+
self._respond_json(400, {
|
|
34181
|
+
"error": "missing or non-string panel",
|
|
34182
|
+
"field": "panel",
|
|
34183
|
+
})
|
|
34184
|
+
return
|
|
34185
|
+
tpl_mod = self._share_load_templates_module()
|
|
34186
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
34187
|
+
self._respond_json(400, {
|
|
34188
|
+
"error": f"unknown share panel: {panel!r}",
|
|
34189
|
+
"field": "panel",
|
|
34190
|
+
})
|
|
34191
|
+
return
|
|
34192
|
+
if not isinstance(name, str) or not name or "/" in name or len(name) > 64:
|
|
34193
|
+
self._respond_json(400, {
|
|
34194
|
+
"error": "name must be 1-64 chars and contain no '/'",
|
|
34195
|
+
"field": "name",
|
|
34196
|
+
})
|
|
34197
|
+
return
|
|
34198
|
+
if not isinstance(template_id, str) or not template_id:
|
|
34199
|
+
self._respond_json(400, {
|
|
34200
|
+
"error": "missing or non-string template_id",
|
|
34201
|
+
"field": "template_id",
|
|
34202
|
+
})
|
|
34203
|
+
return
|
|
34204
|
+
try:
|
|
34205
|
+
template = tpl_mod.get_template(template_id)
|
|
34206
|
+
except KeyError:
|
|
34207
|
+
self._respond_json(400, {
|
|
34208
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
34209
|
+
"field": "template_id",
|
|
34210
|
+
})
|
|
34211
|
+
return
|
|
34212
|
+
if template.panel != panel:
|
|
34213
|
+
self._respond_json(400, {
|
|
34214
|
+
"error": (
|
|
34215
|
+
f"template_id {template_id!r} belongs to panel "
|
|
34216
|
+
f"{template.panel!r}, not {panel!r}"
|
|
34217
|
+
),
|
|
34218
|
+
"field": "template_id",
|
|
34219
|
+
})
|
|
34220
|
+
return
|
|
34221
|
+
if not isinstance(options, dict):
|
|
34222
|
+
self._respond_json(400, {
|
|
34223
|
+
"error": "options must be an object",
|
|
34224
|
+
"field": "options",
|
|
34225
|
+
})
|
|
34226
|
+
return
|
|
34227
|
+
|
|
34228
|
+
saved_at = _share_now_utc_iso()
|
|
34229
|
+
record = {"template_id": template_id, "options": options, "saved_at": saved_at}
|
|
34230
|
+
|
|
34231
|
+
with config_writer_lock():
|
|
34232
|
+
cfg = _load_config_unlocked()
|
|
34233
|
+
share = cfg.setdefault("share", {})
|
|
34234
|
+
presets = share.setdefault("presets", {})
|
|
34235
|
+
panel_bucket = presets.setdefault(panel, {})
|
|
34236
|
+
panel_bucket[name] = record
|
|
34237
|
+
save_config(cfg)
|
|
34238
|
+
self._respond_json(200, {"panel": panel, "name": name, **record})
|
|
34239
|
+
|
|
34240
|
+
def _handle_share_presets_delete(self) -> None:
|
|
34241
|
+
"""Remove a preset by `(panel, name)`.
|
|
34242
|
+
|
|
34243
|
+
Path: ``/api/share/presets/{panel}/{name}``. Missing → 404 so
|
|
34244
|
+
DELETE stays meaningful for idempotency-aware clients. CSRF-gated.
|
|
34245
|
+
"""
|
|
34246
|
+
if not self._check_origin_csrf():
|
|
34247
|
+
return
|
|
34248
|
+
import urllib.parse as _urlparse
|
|
34249
|
+
# Strip the query string defensively; the spec only uses path
|
|
34250
|
+
# segments but a stray "?" shouldn't poison the name token.
|
|
34251
|
+
path_only = self.path.split("?", 1)[0]
|
|
34252
|
+
parts = path_only.split("/")
|
|
34253
|
+
# Expected: ["", "api", "share", "presets", "<panel>", "<name>"]
|
|
34254
|
+
if (
|
|
34255
|
+
len(parts) != 6
|
|
34256
|
+
or parts[1] != "api"
|
|
34257
|
+
or parts[2] != "share"
|
|
34258
|
+
or parts[3] != "presets"
|
|
34259
|
+
or not parts[4]
|
|
34260
|
+
or not parts[5]
|
|
34261
|
+
):
|
|
34262
|
+
self._respond_json(400, {"error": "malformed delete path"})
|
|
34263
|
+
return
|
|
34264
|
+
panel = _urlparse.unquote(parts[4])
|
|
34265
|
+
name = _urlparse.unquote(parts[5])
|
|
34266
|
+
with config_writer_lock():
|
|
34267
|
+
cfg = _load_config_unlocked()
|
|
34268
|
+
share = cfg.get("share") or {}
|
|
34269
|
+
presets = share.get("presets") or {}
|
|
34270
|
+
panel_bucket = presets.get(panel) or {}
|
|
34271
|
+
if name not in panel_bucket:
|
|
34272
|
+
self._respond_json(404, {"error": "no such preset"})
|
|
34273
|
+
return
|
|
34274
|
+
del panel_bucket[name]
|
|
34275
|
+
# Tidy empty buckets so GET stays clean.
|
|
34276
|
+
if not panel_bucket:
|
|
34277
|
+
presets.pop(panel, None)
|
|
34278
|
+
save_config(cfg)
|
|
34279
|
+
self.send_response(204)
|
|
34280
|
+
self.send_header("Content-Length", "0")
|
|
34281
|
+
self.end_headers()
|
|
34282
|
+
|
|
34283
|
+
# ---- /api/share/history — export-recipe ring buffer (spec §5.1, §11.4) ----
|
|
34284
|
+
#
|
|
34285
|
+
# GET /api/share/history → list (newest last) of last 20 export recipes
|
|
34286
|
+
# POST /api/share/history → append; server-side FIFO trim to 20
|
|
34287
|
+
# DELETE /api/share/history → clear the entire buffer
|
|
34288
|
+
#
|
|
34289
|
+
# Persisted under `share.history` in `config.json`. Write discipline
|
|
34290
|
+
# matches the presets handlers above: `config_writer_lock` +
|
|
34291
|
+
# `_load_config_unlocked` + `save_config`. GET is unauthenticated
|
|
34292
|
+
# like `/api/share/templates`; POST + DELETE go through
|
|
34293
|
+
# `_check_origin_csrf`. The frontend posts fire-and-forget after
|
|
34294
|
+
# every successful export — history failures are non-fatal.
|
|
34295
|
+
|
|
34296
|
+
def _handle_share_history_get(self) -> None:
|
|
34297
|
+
"""Return the recent-shares ring buffer (newest last, spec §11.4)."""
|
|
34298
|
+
cfg = load_config()
|
|
34299
|
+
history = (cfg.get("share") or {}).get("history") or []
|
|
34300
|
+
self._respond_json(200, {"history": history})
|
|
34301
|
+
|
|
34302
|
+
def _handle_share_history_post(self) -> None:
|
|
34303
|
+
"""Append a recipe to the ring buffer; FIFO trim to 20.
|
|
34304
|
+
|
|
34305
|
+
Body: ``{panel, template_id, options, format, destination}``. The
|
|
34306
|
+
server stamps ``recipe_id`` (random hex) and ``exported_at``
|
|
34307
|
+
(UTC ISO-8601) so the client doesn't need a clock or a UUID lib.
|
|
34308
|
+
CSRF-gated. Read-modify-write under ``config_writer_lock`` —
|
|
34309
|
+
same pattern as the presets POST.
|
|
34310
|
+
"""
|
|
34311
|
+
if not self._check_origin_csrf():
|
|
34312
|
+
return
|
|
34313
|
+
try:
|
|
34314
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
34315
|
+
except ValueError:
|
|
34316
|
+
length = 0
|
|
34317
|
+
try:
|
|
34318
|
+
raw = self.rfile.read(length) if length > 0 else b""
|
|
34319
|
+
req = json.loads(raw) if raw else {}
|
|
34320
|
+
except (ValueError, json.JSONDecodeError):
|
|
34321
|
+
self._respond_json(400, {"error": "malformed json"})
|
|
34322
|
+
return
|
|
34323
|
+
if not isinstance(req, dict):
|
|
34324
|
+
self._respond_json(400, {"error": "expected JSON object"})
|
|
34325
|
+
return
|
|
34326
|
+
panel = req.get("panel")
|
|
34327
|
+
template_id = req.get("template_id")
|
|
34328
|
+
options = req.get("options") or {}
|
|
34329
|
+
fmt = req.get("format")
|
|
34330
|
+
destination = req.get("destination")
|
|
34331
|
+
if not isinstance(panel, str) or not panel:
|
|
34332
|
+
self._respond_json(400, {
|
|
34333
|
+
"error": "missing or non-string panel",
|
|
34334
|
+
"field": "panel",
|
|
34335
|
+
})
|
|
34336
|
+
return
|
|
34337
|
+
tpl_mod = self._share_load_templates_module()
|
|
34338
|
+
if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
|
|
34339
|
+
self._respond_json(400, {
|
|
34340
|
+
"error": f"unknown share panel: {panel!r}",
|
|
34341
|
+
"field": "panel",
|
|
34342
|
+
})
|
|
34343
|
+
return
|
|
34344
|
+
if not isinstance(template_id, str) or not template_id:
|
|
34345
|
+
self._respond_json(400, {
|
|
34346
|
+
"error": "missing or non-string template_id",
|
|
34347
|
+
"field": "template_id",
|
|
34348
|
+
})
|
|
34349
|
+
return
|
|
34350
|
+
try:
|
|
34351
|
+
template = tpl_mod.get_template(template_id)
|
|
34352
|
+
except KeyError:
|
|
34353
|
+
self._respond_json(400, {
|
|
34354
|
+
"error": f"unknown template_id: {template_id!r}",
|
|
34355
|
+
"field": "template_id",
|
|
34356
|
+
})
|
|
34357
|
+
return
|
|
34358
|
+
if template.panel != panel:
|
|
34359
|
+
self._respond_json(400, {
|
|
34360
|
+
"error": (
|
|
34361
|
+
f"template_id {template_id!r} belongs to panel "
|
|
34362
|
+
f"{template.panel!r}, not {panel!r}"
|
|
34363
|
+
),
|
|
34364
|
+
"field": "template_id",
|
|
34365
|
+
})
|
|
34366
|
+
return
|
|
34367
|
+
if not isinstance(options, dict):
|
|
34368
|
+
self._respond_json(400, {
|
|
34369
|
+
"error": "options must be an object",
|
|
34370
|
+
"field": "options",
|
|
34371
|
+
})
|
|
34372
|
+
return
|
|
34373
|
+
# `format` and `destination` are advisory strings — accept any
|
|
34374
|
+
# non-empty string; the frontend uses them only as display hints
|
|
34375
|
+
# in the dropdown row. None/missing is allowed (mirrors how the
|
|
34376
|
+
# CLI doesn't always know which destination produced the export).
|
|
34377
|
+
if fmt is not None and not isinstance(fmt, str):
|
|
34378
|
+
self._respond_json(400, {
|
|
34379
|
+
"error": "format must be a string if provided",
|
|
34380
|
+
"field": "format",
|
|
34381
|
+
})
|
|
34382
|
+
return
|
|
34383
|
+
if destination is not None and not isinstance(destination, str):
|
|
34384
|
+
self._respond_json(400, {
|
|
34385
|
+
"error": "destination must be a string if provided",
|
|
34386
|
+
"field": "destination",
|
|
34387
|
+
})
|
|
34388
|
+
return
|
|
34389
|
+
|
|
34390
|
+
record = {
|
|
34391
|
+
"recipe_id": _share_history_recipe_id(),
|
|
34392
|
+
"panel": panel,
|
|
34393
|
+
"template_id": template_id,
|
|
34394
|
+
"options": options,
|
|
34395
|
+
"format": fmt,
|
|
34396
|
+
"destination": destination,
|
|
34397
|
+
"exported_at": _share_now_utc_iso(),
|
|
34398
|
+
}
|
|
34399
|
+
with config_writer_lock():
|
|
34400
|
+
cfg = _load_config_unlocked()
|
|
34401
|
+
share = cfg.setdefault("share", {})
|
|
34402
|
+
history = share.setdefault("history", [])
|
|
34403
|
+
history.append(record)
|
|
34404
|
+
# Ring buffer: trim from the front so the newest is always
|
|
34405
|
+
# last. `del history[:n]` keeps the same list instance, so
|
|
34406
|
+
# callers holding a reference (none in this scope, but a
|
|
34407
|
+
# safe invariant) see the same object mutated in place.
|
|
34408
|
+
if len(history) > _SHARE_HISTORY_RING_CAP:
|
|
34409
|
+
del history[: len(history) - _SHARE_HISTORY_RING_CAP]
|
|
34410
|
+
save_config(cfg)
|
|
34411
|
+
self._respond_json(200, record)
|
|
34412
|
+
|
|
34413
|
+
def _handle_share_history_delete(self) -> None:
|
|
34414
|
+
"""Empty the share-history ring buffer (spec §11.4)."""
|
|
34415
|
+
if not self._check_origin_csrf():
|
|
34416
|
+
return
|
|
34417
|
+
with config_writer_lock():
|
|
34418
|
+
cfg = _load_config_unlocked()
|
|
34419
|
+
share = cfg.get("share")
|
|
34420
|
+
if isinstance(share, dict) and "history" in share:
|
|
34421
|
+
share["history"] = []
|
|
34422
|
+
save_config(cfg)
|
|
34423
|
+
self.send_response(204)
|
|
34424
|
+
self.send_header("Content-Length", "0")
|
|
34425
|
+
self.end_headers()
|
|
34426
|
+
|
|
32774
34427
|
# ---- helpers ----
|
|
32775
34428
|
|
|
32776
34429
|
def _respond_json(self, status: int, body: dict) -> None:
|