codex-stats 1.3.0__tar.gz → 1.4.2__tar.gz
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.
- {codex_stats-1.3.0/src/codex_stats.egg-info → codex_stats-1.4.2}/PKG-INFO +14 -2
- {codex_stats-1.3.0 → codex_stats-1.4.2}/README.md +13 -1
- {codex_stats-1.3.0 → codex_stats-1.4.2}/pyproject.toml +1 -1
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/__init__.py +1 -1
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/cli.py +83 -10
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/display.py +304 -2
- {codex_stats-1.3.0 → codex_stats-1.4.2/src/codex_stats.egg-info}/PKG-INFO +14 -2
- {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_cli.py +38 -4
- {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_metrics.py +18 -2
- {codex_stats-1.3.0 → codex_stats-1.4.2}/LICENSE +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/setup.cfg +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/__main__.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/completions.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/config.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/ingest.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/metrics.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/models.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/otel.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/transfer.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats/watch_state.py +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/SOURCES.txt +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/dependency_links.txt +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/entry_points.txt +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/requires.txt +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/src/codex_stats.egg-info/top_level.txt +0 -0
- {codex_stats-1.3.0 → codex_stats-1.4.2}/tests/test_config.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-stats
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: Local AI usage observability CLI for Codex sessions.
|
|
5
5
|
Author: Vivek
|
|
6
6
|
License: MIT
|
|
@@ -108,13 +108,21 @@ python3 -m pip install codex-stats
|
|
|
108
108
|
- `codex-stats report weekly --format markdown`
|
|
109
109
|
Generate a weekly report in Markdown.
|
|
110
110
|
- `codex-stats report weekly --format html`
|
|
111
|
-
Generate a standalone HTML report for sharing.
|
|
111
|
+
Generate a standalone HTML report for sharing, including inline charts.
|
|
112
|
+
- `codex-stats report weekly --format svg`
|
|
113
|
+
Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
|
|
112
114
|
- `codex-stats report weekly --project backend-api`
|
|
113
115
|
Generate a weekly report for one project.
|
|
114
116
|
- `codex-stats report weekly --format markdown --output weekly-report.md`
|
|
115
117
|
Write a formatted report to a file.
|
|
116
118
|
- `codex-stats report weekly --format html --output weekly-report.html`
|
|
117
119
|
Write a polished standalone HTML report to a file.
|
|
120
|
+
- `codex-stats report weekly --format svg --output weekly-report-assets`
|
|
121
|
+
Write the SVG asset bundle into the output directory you provide.
|
|
122
|
+
- `codex-stats report weekly --format html --render`
|
|
123
|
+
Generate an HTML report, write it to a temp file, and open it in the default browser.
|
|
124
|
+
- `codex-stats report weekly --format svg --render`
|
|
125
|
+
Generate an SVG report, write it to a temp file, and open it in the default browser.
|
|
118
126
|
- `codex-stats export codex-stats-export.json`
|
|
119
127
|
Export normalized local stats to JSON.
|
|
120
128
|
- `codex-stats export codex-stats-export.json --since 30d`
|
|
@@ -217,6 +225,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
|
|
|
217
225
|
|
|
218
226
|
The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
|
|
219
227
|
|
|
228
|
+
## Shareable Assets
|
|
229
|
+
|
|
230
|
+
Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
|
|
231
|
+
|
|
220
232
|
## Watch Alerts
|
|
221
233
|
|
|
222
234
|
`watch` can surface live warnings and critical alerts directly in the terminal dashboard.
|
|
@@ -81,13 +81,21 @@ python3 -m pip install codex-stats
|
|
|
81
81
|
- `codex-stats report weekly --format markdown`
|
|
82
82
|
Generate a weekly report in Markdown.
|
|
83
83
|
- `codex-stats report weekly --format html`
|
|
84
|
-
Generate a standalone HTML report for sharing.
|
|
84
|
+
Generate a standalone HTML report for sharing, including inline charts.
|
|
85
|
+
- `codex-stats report weekly --format svg`
|
|
86
|
+
Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
|
|
85
87
|
- `codex-stats report weekly --project backend-api`
|
|
86
88
|
Generate a weekly report for one project.
|
|
87
89
|
- `codex-stats report weekly --format markdown --output weekly-report.md`
|
|
88
90
|
Write a formatted report to a file.
|
|
89
91
|
- `codex-stats report weekly --format html --output weekly-report.html`
|
|
90
92
|
Write a polished standalone HTML report to a file.
|
|
93
|
+
- `codex-stats report weekly --format svg --output weekly-report-assets`
|
|
94
|
+
Write the SVG asset bundle into the output directory you provide.
|
|
95
|
+
- `codex-stats report weekly --format html --render`
|
|
96
|
+
Generate an HTML report, write it to a temp file, and open it in the default browser.
|
|
97
|
+
- `codex-stats report weekly --format svg --render`
|
|
98
|
+
Generate an SVG report, write it to a temp file, and open it in the default browser.
|
|
91
99
|
- `codex-stats export codex-stats-export.json`
|
|
92
100
|
Export normalized local stats to JSON.
|
|
93
101
|
- `codex-stats export codex-stats-export.json --since 30d`
|
|
@@ -190,6 +198,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
|
|
|
190
198
|
|
|
191
199
|
The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
|
|
192
200
|
|
|
201
|
+
## Shareable Assets
|
|
202
|
+
|
|
203
|
+
Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
|
|
204
|
+
|
|
193
205
|
## Watch Alerts
|
|
194
206
|
|
|
195
207
|
`watch` can surface live warnings and critical alerts directly in the terminal dashboard.
|
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
import tempfile
|
|
6
7
|
import time
|
|
8
|
+
import webbrowser
|
|
7
9
|
from datetime import datetime, timedelta
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
|
|
@@ -23,6 +25,8 @@ from .display import (
|
|
|
23
25
|
format_report,
|
|
24
26
|
format_report_html,
|
|
25
27
|
format_report_markdown,
|
|
28
|
+
format_report_svg_assets,
|
|
29
|
+
format_report_svg,
|
|
26
30
|
format_session,
|
|
27
31
|
format_summary,
|
|
28
32
|
format_top,
|
|
@@ -152,9 +156,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
152
156
|
|
|
153
157
|
report_parser = subparsers.add_parser("report", help="Generate a shareable usage report.")
|
|
154
158
|
report_parser.add_argument("period", choices=["weekly", "monthly"], help="Report period.")
|
|
155
|
-
report_parser.add_argument("--format", choices=["text", "markdown", "html", "json"], default="text", help="Output format.")
|
|
159
|
+
report_parser.add_argument("--format", choices=["text", "markdown", "html", "svg", "json"], default="text", help="Output format.")
|
|
156
160
|
report_parser.add_argument("--project", dest="project_name", help="Generate the report for one project.")
|
|
157
161
|
report_parser.add_argument("--output", help="Write the report to a file instead of stdout.")
|
|
162
|
+
report_parser.add_argument("--render", action="store_true", help="Open an HTML report in the default browser.")
|
|
158
163
|
|
|
159
164
|
merge_parser = subparsers.add_parser("merge", help="Merge one or more exported stats files into one snapshot.")
|
|
160
165
|
merge_parser.add_argument("output", help="Merged output JSON file.")
|
|
@@ -398,20 +403,46 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
398
403
|
return 0
|
|
399
404
|
|
|
400
405
|
if args.command == "report":
|
|
406
|
+
if args.render and args.format not in {"html", "svg"}:
|
|
407
|
+
print("--render requires --format html or --format svg", file=sys.stderr)
|
|
408
|
+
return 1
|
|
401
409
|
report = build_report(paths, period=args.period, project_name=args.project_name)
|
|
410
|
+
daily_points = None
|
|
411
|
+
if args.format in {"html", "svg"}:
|
|
412
|
+
report_days = 7 if args.period == "weekly" else 30
|
|
413
|
+
report_details = filter_details_by_project(details_for_last_days(paths, report_days), args.project_name)
|
|
414
|
+
daily_points = summarize_daily_from_details(report_details, days=report_days)
|
|
402
415
|
if args.format == "json":
|
|
403
416
|
content = as_json(report.to_dict())
|
|
404
417
|
elif args.format == "html":
|
|
405
|
-
content = format_report_html(report)
|
|
418
|
+
content = format_report_html(report, daily_points=daily_points)
|
|
419
|
+
elif args.format == "svg":
|
|
420
|
+
assets = format_report_svg_assets(report, daily_points=daily_points)
|
|
421
|
+
output_dir = _write_report_svg_assets(
|
|
422
|
+
assets,
|
|
423
|
+
args.output,
|
|
424
|
+
base_name=_default_report_basename(args.period, args.project_name),
|
|
425
|
+
)
|
|
426
|
+
print(f"Wrote SVG assets to {output_dir}")
|
|
427
|
+
if args.render:
|
|
428
|
+
summary_path = output_dir / f"{_default_report_basename(args.period, args.project_name)}-summary-card.svg"
|
|
429
|
+
_open_report_in_browser(summary_path)
|
|
430
|
+
print(f"Opened report in browser: {summary_path}")
|
|
431
|
+
return 0
|
|
406
432
|
elif args.format == "markdown":
|
|
407
433
|
content = format_report_markdown(report)
|
|
408
434
|
else:
|
|
409
435
|
content = format_report(report, options)
|
|
410
|
-
if args.output:
|
|
411
|
-
output_path =
|
|
412
|
-
|
|
413
|
-
|
|
436
|
+
if args.output or args.render:
|
|
437
|
+
output_path = _write_report_output(
|
|
438
|
+
content,
|
|
439
|
+
args.output,
|
|
440
|
+
suffix=f".{args.format}" if args.format in {"html", "svg"} else ".txt",
|
|
441
|
+
)
|
|
414
442
|
print(f"Wrote report to {output_path}")
|
|
443
|
+
if args.render:
|
|
444
|
+
_open_report_in_browser(output_path)
|
|
445
|
+
print(f"Opened report in browser: {output_path}")
|
|
415
446
|
else:
|
|
416
447
|
print(content)
|
|
417
448
|
return 0
|
|
@@ -585,10 +616,52 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
585
616
|
parser.print_help()
|
|
586
617
|
return 1
|
|
587
618
|
|
|
619
|
+
def summarize_details_for_range(days: int, details):
|
|
620
|
+
return summarize_imported_details(details, label=f"last {max(days, 1)} days")
|
|
588
621
|
|
|
589
|
-
if __name__ == "__main__":
|
|
590
|
-
raise SystemExit(main())
|
|
591
622
|
|
|
623
|
+
def _write_report_output(content: str, output: str | None, *, suffix: str, default_name: str | None = None) -> Path:
|
|
624
|
+
if output:
|
|
625
|
+
output_path = Path(output).expanduser()
|
|
626
|
+
elif default_name:
|
|
627
|
+
output_path = Path.cwd() / default_name
|
|
628
|
+
else:
|
|
629
|
+
handle = tempfile.NamedTemporaryFile(prefix="codex-stats-report-", suffix=suffix, delete=False)
|
|
630
|
+
handle.close()
|
|
631
|
+
output_path = Path(handle.name)
|
|
632
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
output_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
|
|
634
|
+
return output_path
|
|
592
635
|
|
|
593
|
-
|
|
594
|
-
|
|
636
|
+
|
|
637
|
+
def _open_report_in_browser(path: Path) -> None:
|
|
638
|
+
webbrowser.open(path.resolve().as_uri())
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _default_report_filename(period: str, project_name: str | None, fmt: str) -> str:
|
|
642
|
+
if project_name:
|
|
643
|
+
safe_project = "".join(char.lower() if char.isalnum() else "-" for char in project_name).strip("-")
|
|
644
|
+
safe_project = safe_project or "project"
|
|
645
|
+
return f"codex-stats-{period}-{safe_project}.{fmt}"
|
|
646
|
+
return f"codex-stats-{period}.{fmt}"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _default_report_basename(period: str, project_name: str | None) -> str:
|
|
650
|
+
if project_name:
|
|
651
|
+
safe_project = "".join(char.lower() if char.isalnum() else "-" for char in project_name).strip("-")
|
|
652
|
+
safe_project = safe_project or "project"
|
|
653
|
+
return f"codex-stats-{period}-{safe_project}"
|
|
654
|
+
return f"codex-stats-{period}"
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _write_report_svg_assets(assets: dict[str, str], output: str | None, *, base_name: str) -> Path:
|
|
658
|
+
output_dir = Path(output).expanduser() if output else Path.cwd()
|
|
659
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
for asset_name, content in assets.items():
|
|
661
|
+
asset_path = output_dir / f"{base_name}-{asset_name}.svg"
|
|
662
|
+
asset_path.write_text(content + ("" if content.endswith("\n") else "\n"), encoding="utf-8")
|
|
663
|
+
return output_dir
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
if __name__ == "__main__":
|
|
667
|
+
raise SystemExit(main())
|
|
@@ -308,12 +308,13 @@ def format_report_markdown(report: ReportData) -> str:
|
|
|
308
308
|
return "\n".join(lines)
|
|
309
309
|
|
|
310
310
|
|
|
311
|
-
def format_report_html(report: ReportData) -> str:
|
|
311
|
+
def format_report_html(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
|
|
312
312
|
title = f"Codex Stats {report.period.title()} Report"
|
|
313
313
|
if report.project_name:
|
|
314
314
|
title = f"{title}: {report.project_name}"
|
|
315
315
|
delta_pct = "n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%"
|
|
316
316
|
delta_color = "var(--warn)" if delta_pct.startswith("+") else "var(--good)"
|
|
317
|
+
daily_points = daily_points or []
|
|
317
318
|
projects_html = ""
|
|
318
319
|
if report.project_name is None:
|
|
319
320
|
if report.projects:
|
|
@@ -371,6 +372,30 @@ def format_report_html(report: ReportData) -> str:
|
|
|
371
372
|
|
|
372
373
|
anomalies_html = "".join(f"<li>{escape(item)}</li>" for item in report.insights.anomalies) or "<li>none</li>"
|
|
373
374
|
recommendations_html = "".join(f"<li>{escape(item)}</li>" for item in report.insights.recommendations) or "<li>none</li>"
|
|
375
|
+
token_trend_svg = _svg_line_chart(
|
|
376
|
+
[(point.day[5:], float(point.total_tokens)) for point in daily_points],
|
|
377
|
+
stroke="#0f766e",
|
|
378
|
+
fill="rgba(15, 118, 110, 0.12)",
|
|
379
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
380
|
+
)
|
|
381
|
+
cost_trend_svg = _svg_line_chart(
|
|
382
|
+
[(point.day[5:], float(point.estimated_cost_usd)) for point in daily_points],
|
|
383
|
+
stroke="#b45309",
|
|
384
|
+
fill="rgba(180, 83, 9, 0.14)",
|
|
385
|
+
value_formatter=lambda value: f"${value:.2f}",
|
|
386
|
+
)
|
|
387
|
+
project_share_svg = _svg_bar_chart(
|
|
388
|
+
[(entry.name, float(entry.total_tokens)) for entry in report.projects[:5]],
|
|
389
|
+
bar_color="#0f766e",
|
|
390
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
391
|
+
empty_label="No project breakdown for this scoped report.",
|
|
392
|
+
)
|
|
393
|
+
session_share_svg = _svg_bar_chart(
|
|
394
|
+
[(f"{entry.project_name} / {entry.model or 'unknown'}", float(entry.total_tokens)) for entry in report.top_sessions[:5]],
|
|
395
|
+
bar_color="#b45309",
|
|
396
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
397
|
+
empty_label="No top session data available.",
|
|
398
|
+
)
|
|
374
399
|
|
|
375
400
|
return f"""<!DOCTYPE html>
|
|
376
401
|
<html lang="en">
|
|
@@ -572,6 +597,31 @@ def format_report_html(report: ReportData) -> str:
|
|
|
572
597
|
.table-wrap {{
|
|
573
598
|
overflow-x: auto;
|
|
574
599
|
}}
|
|
600
|
+
.chart-grid {{
|
|
601
|
+
display: grid;
|
|
602
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
603
|
+
gap: 18px;
|
|
604
|
+
}}
|
|
605
|
+
.chart-card {{
|
|
606
|
+
background: var(--panel-strong);
|
|
607
|
+
border: 1px solid var(--line);
|
|
608
|
+
border-radius: 18px;
|
|
609
|
+
padding: 16px;
|
|
610
|
+
}}
|
|
611
|
+
.chart-card h3 {{
|
|
612
|
+
margin: 0 0 10px;
|
|
613
|
+
font-size: 1rem;
|
|
614
|
+
}}
|
|
615
|
+
.chart-svg {{
|
|
616
|
+
width: 100%;
|
|
617
|
+
height: auto;
|
|
618
|
+
display: block;
|
|
619
|
+
}}
|
|
620
|
+
.chart-empty {{
|
|
621
|
+
color: var(--muted);
|
|
622
|
+
font-size: 0.95rem;
|
|
623
|
+
padding: 12px 0 6px;
|
|
624
|
+
}}
|
|
575
625
|
.list-grid {{
|
|
576
626
|
display: grid;
|
|
577
627
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -592,7 +642,7 @@ def format_report_html(report: ReportData) -> str:
|
|
|
592
642
|
text-align: right;
|
|
593
643
|
}}
|
|
594
644
|
@media (max-width: 900px) {{
|
|
595
|
-
.hero-grid, .split, .list-grid, .metrics {{ grid-template-columns: 1fr; }}
|
|
645
|
+
.hero-grid, .split, .list-grid, .metrics, .chart-grid {{ grid-template-columns: 1fr; }}
|
|
596
646
|
}}
|
|
597
647
|
</style>
|
|
598
648
|
</head>
|
|
@@ -649,6 +699,30 @@ def format_report_html(report: ReportData) -> str:
|
|
|
649
699
|
</div>
|
|
650
700
|
</section>
|
|
651
701
|
|
|
702
|
+
<section class="panel">
|
|
703
|
+
<div class="section-header">
|
|
704
|
+
<h2>Charts</h2>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="chart-grid">
|
|
707
|
+
<div class="chart-card">
|
|
708
|
+
<h3>Daily Token Trend</h3>
|
|
709
|
+
{token_trend_svg}
|
|
710
|
+
</div>
|
|
711
|
+
<div class="chart-card">
|
|
712
|
+
<h3>Daily Cost Trend</h3>
|
|
713
|
+
{cost_trend_svg}
|
|
714
|
+
</div>
|
|
715
|
+
<div class="chart-card">
|
|
716
|
+
<h3>Project Share</h3>
|
|
717
|
+
{project_share_svg}
|
|
718
|
+
</div>
|
|
719
|
+
<div class="chart-card">
|
|
720
|
+
<h3>Top Sessions by Tokens</h3>
|
|
721
|
+
{session_share_svg}
|
|
722
|
+
</div>
|
|
723
|
+
</div>
|
|
724
|
+
</section>
|
|
725
|
+
|
|
652
726
|
{projects_html}
|
|
653
727
|
|
|
654
728
|
<section class="panel">
|
|
@@ -714,6 +788,234 @@ def format_report_html(report: ReportData) -> str:
|
|
|
714
788
|
"""
|
|
715
789
|
|
|
716
790
|
|
|
791
|
+
def format_report_svg_assets(report: ReportData, daily_points: list[DailyPoint] | None = None) -> dict[str, str]:
|
|
792
|
+
title = f"Codex Stats {report.period.title()} Report"
|
|
793
|
+
if report.project_name:
|
|
794
|
+
title = f"{title}: {report.project_name}"
|
|
795
|
+
daily_points = daily_points or []
|
|
796
|
+
assets = {
|
|
797
|
+
"summary-card": _format_summary_card_svg(report, title),
|
|
798
|
+
"daily-tokens": _wrap_chart_svg(
|
|
799
|
+
title="Daily Token Trend",
|
|
800
|
+
subtitle=title,
|
|
801
|
+
chart_svg=_svg_line_chart(
|
|
802
|
+
[(point.day[5:], float(point.total_tokens)) for point in daily_points],
|
|
803
|
+
stroke="#0f766e",
|
|
804
|
+
fill="rgba(15, 118, 110, 0.14)",
|
|
805
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
806
|
+
),
|
|
807
|
+
),
|
|
808
|
+
"daily-cost": _wrap_chart_svg(
|
|
809
|
+
title="Daily Cost Trend",
|
|
810
|
+
subtitle=title,
|
|
811
|
+
chart_svg=_svg_line_chart(
|
|
812
|
+
[(point.day[5:], float(point.estimated_cost_usd)) for point in daily_points],
|
|
813
|
+
stroke="#b45309",
|
|
814
|
+
fill="rgba(180, 83, 9, 0.14)",
|
|
815
|
+
value_formatter=lambda value: f"${value:.2f}",
|
|
816
|
+
),
|
|
817
|
+
),
|
|
818
|
+
"project-share": _wrap_chart_svg(
|
|
819
|
+
title="Project Share",
|
|
820
|
+
subtitle=title,
|
|
821
|
+
chart_svg=_svg_bar_chart(
|
|
822
|
+
[(entry.name, float(entry.total_tokens)) for entry in report.projects[:5]],
|
|
823
|
+
bar_color="#0f766e",
|
|
824
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
825
|
+
empty_label="No project breakdown for this scoped report.",
|
|
826
|
+
),
|
|
827
|
+
),
|
|
828
|
+
"top-sessions": _wrap_chart_svg(
|
|
829
|
+
title="Top Sessions",
|
|
830
|
+
subtitle=title,
|
|
831
|
+
chart_svg=_svg_bar_chart(
|
|
832
|
+
[(f"{entry.project_name} / {entry.model or 'unknown'}", float(entry.total_tokens)) for entry in report.top_sessions[:5]],
|
|
833
|
+
bar_color="#b45309",
|
|
834
|
+
value_formatter=lambda value: f"{int(value):,}",
|
|
835
|
+
empty_label="No top session data available.",
|
|
836
|
+
),
|
|
837
|
+
),
|
|
838
|
+
}
|
|
839
|
+
return assets
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def format_report_svg(report: ReportData, daily_points: list[DailyPoint] | None = None) -> str:
|
|
843
|
+
return format_report_svg_assets(report, daily_points)["summary-card"]
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _svg_line_chart(
|
|
847
|
+
points: list[tuple[str, float]],
|
|
848
|
+
*,
|
|
849
|
+
stroke: str,
|
|
850
|
+
fill: str,
|
|
851
|
+
value_formatter,
|
|
852
|
+
) -> str:
|
|
853
|
+
if not points:
|
|
854
|
+
return '<div class="chart-empty">No data available.</div>'
|
|
855
|
+
width = 760
|
|
856
|
+
height = 240
|
|
857
|
+
padding_left = 48
|
|
858
|
+
padding_right = 20
|
|
859
|
+
padding_top = 20
|
|
860
|
+
padding_bottom = 38
|
|
861
|
+
values = [value for _, value in points]
|
|
862
|
+
max_value = max(values) if max(values) > 0 else 1.0
|
|
863
|
+
inner_width = width - padding_left - padding_right
|
|
864
|
+
inner_height = height - padding_top - padding_bottom
|
|
865
|
+
|
|
866
|
+
def point_x(index: int) -> float:
|
|
867
|
+
if len(points) == 1:
|
|
868
|
+
return padding_left + inner_width / 2
|
|
869
|
+
return padding_left + (index / (len(points) - 1)) * inner_width
|
|
870
|
+
|
|
871
|
+
def point_y(value: float) -> float:
|
|
872
|
+
return padding_top + inner_height - (value / max_value) * inner_height
|
|
873
|
+
|
|
874
|
+
line_points = " ".join(f"{point_x(index):.1f},{point_y(value):.1f}" for index, (_, value) in enumerate(points))
|
|
875
|
+
area_points = f"{padding_left:.1f},{padding_top + inner_height:.1f} {line_points} {padding_left + inner_width:.1f},{padding_top + inner_height:.1f}"
|
|
876
|
+
labels = "".join(
|
|
877
|
+
f'<text x="{point_x(index):.1f}" y="{height - 12}" text-anchor="middle" font-size="11" fill="#6d645d">{escape(label)}</text>'
|
|
878
|
+
for index, (label, _) in enumerate(points)
|
|
879
|
+
)
|
|
880
|
+
dots = "".join(
|
|
881
|
+
f'<circle cx="{point_x(index):.1f}" cy="{point_y(value):.1f}" r="4" fill="{stroke}"><title>{escape(label)}: {escape(value_formatter(value))}</title></circle>'
|
|
882
|
+
for index, (label, value) in enumerate(points)
|
|
883
|
+
)
|
|
884
|
+
grid = "".join(
|
|
885
|
+
f'<line x1="{padding_left}" y1="{padding_top + step * (inner_height / 3):.1f}" x2="{width - padding_right}" y2="{padding_top + step * (inner_height / 3):.1f}" stroke="rgba(72,53,36,0.12)" stroke-dasharray="4 6" />'
|
|
886
|
+
for step in range(4)
|
|
887
|
+
)
|
|
888
|
+
y_labels = "".join(
|
|
889
|
+
f'<text x="{padding_left - 8}" y="{padding_top + inner_height - step * (inner_height / 3) + 4:.1f}" text-anchor="end" font-size="11" fill="#6d645d">{escape(value_formatter((max_value / 3) * step))}</text>'
|
|
890
|
+
for step in range(4)
|
|
891
|
+
)
|
|
892
|
+
return (
|
|
893
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Trend chart">'
|
|
894
|
+
f'{grid}'
|
|
895
|
+
f'<polygon points="{area_points}" fill="{fill}" />'
|
|
896
|
+
f'<polyline points="{line_points}" fill="none" stroke="{stroke}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />'
|
|
897
|
+
f'{dots}{labels}{y_labels}</svg>'
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _svg_bar_chart(
|
|
902
|
+
bars: list[tuple[str, float]],
|
|
903
|
+
*,
|
|
904
|
+
bar_color: str,
|
|
905
|
+
value_formatter,
|
|
906
|
+
empty_label: str,
|
|
907
|
+
) -> str:
|
|
908
|
+
if not bars:
|
|
909
|
+
return f'<div class="chart-empty">{escape(empty_label)}</div>'
|
|
910
|
+
width = 760
|
|
911
|
+
row_height = 34
|
|
912
|
+
padding = 18
|
|
913
|
+
label_width = 170
|
|
914
|
+
bar_max_width = width - (padding * 2) - label_width - 90
|
|
915
|
+
height = padding * 2 + row_height * len(bars)
|
|
916
|
+
max_value = max(value for _, value in bars) if max(value for _, value in bars) > 0 else 1.0
|
|
917
|
+
rows = []
|
|
918
|
+
for index, (label, value) in enumerate(bars):
|
|
919
|
+
y = padding + index * row_height
|
|
920
|
+
bar_width = (value / max_value) * bar_max_width
|
|
921
|
+
rows.append(
|
|
922
|
+
f'<text x="{padding}" y="{y + 20}" font-size="12" fill="#1f1a17">{escape(label)}</text>'
|
|
923
|
+
f'<rect x="{padding + label_width}" y="{y + 6}" width="{bar_max_width:.1f}" height="16" rx="8" fill="rgba(72,53,36,0.08)" />'
|
|
924
|
+
f'<rect x="{padding + label_width}" y="{y + 6}" width="{bar_width:.1f}" height="16" rx="8" fill="{bar_color}" />'
|
|
925
|
+
f'<text x="{padding + label_width + bar_max_width + 10}" y="{y + 19}" font-size="12" fill="#6d645d">{escape(value_formatter(value))}</text>'
|
|
926
|
+
)
|
|
927
|
+
return f'<svg xmlns="http://www.w3.org/2000/svg" class="chart-svg" viewBox="0 0 {width} {height}" role="img" aria-label="Bar chart">{"".join(rows)}</svg>'
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _format_summary_card_svg(report: ReportData, title: str) -> str:
|
|
931
|
+
delta_text = "n/a" if report.comparison.total_tokens_delta_pct is None else f"{report.comparison.total_tokens_delta_pct:+.1f}%"
|
|
932
|
+
anomalies = report.insights.anomalies[:2] or ["No active anomalies"]
|
|
933
|
+
recommendations = report.insights.recommendations[:2] or ["Usage looks healthy."]
|
|
934
|
+
first_action = recommendations[0]
|
|
935
|
+
second_action = recommendations[1] if len(recommendations) > 1 else recommendations[0]
|
|
936
|
+
first_anomaly = anomalies[0]
|
|
937
|
+
second_anomaly = anomalies[1] if len(anomalies) > 1 else anomalies[0]
|
|
938
|
+
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="{escape(title)}">
|
|
939
|
+
<defs>
|
|
940
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
941
|
+
<stop offset="0%" stop-color="#fbf5ec"/>
|
|
942
|
+
<stop offset="100%" stop-color="#efe2cf"/>
|
|
943
|
+
</linearGradient>
|
|
944
|
+
<linearGradient id="teal" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
945
|
+
<stop offset="0%" stop-color="#115e59"/>
|
|
946
|
+
<stop offset="100%" stop-color="#0f766e"/>
|
|
947
|
+
</linearGradient>
|
|
948
|
+
<linearGradient id="amber" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
949
|
+
<stop offset="0%" stop-color="#b45309"/>
|
|
950
|
+
<stop offset="100%" stop-color="#d97706"/>
|
|
951
|
+
</linearGradient>
|
|
952
|
+
</defs>
|
|
953
|
+
<rect width="1200" height="630" fill="url(#bg)"/>
|
|
954
|
+
<rect x="34" y="28" width="1132" height="574" rx="32" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
955
|
+
|
|
956
|
+
<circle cx="1056" cy="126" r="118" fill="rgba(15,118,110,0.10)"/>
|
|
957
|
+
<circle cx="1116" cy="206" r="72" fill="rgba(217,119,6,0.10)"/>
|
|
958
|
+
<circle cx="1012" cy="214" r="18" fill="#0f766e"/>
|
|
959
|
+
<circle cx="1066" cy="164" r="18" fill="#d97706"/>
|
|
960
|
+
<circle cx="1120" cy="114" r="18" fill="#0f766e"/>
|
|
961
|
+
|
|
962
|
+
<text x="74" y="86" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
|
|
963
|
+
<text x="74" y="150" font-size="60" font-weight="700" fill="#1f1a17">{escape(title)}</text>
|
|
964
|
+
<text x="74" y="194" font-size="24" fill="#6d645d">Compact share card for release notes, README embeds, and social posts.</text>
|
|
965
|
+
|
|
966
|
+
<rect x="74" y="226" width="336" height="52" rx="26" fill="rgba(15,118,110,0.08)"/>
|
|
967
|
+
<text x="102" y="258" font-size="20" fill="#134e4a">Top model {escape(report.summary.top_model or "unknown")}</text>
|
|
968
|
+
<rect x="426" y="226" width="190" height="52" rx="26" fill="rgba(180,83,9,0.10)"/>
|
|
969
|
+
<text x="454" y="258" font-size="20" fill="#92400e">Trend {escape(delta_text)}</text>
|
|
970
|
+
<rect x="632" y="226" width="214" height="52" rx="26" fill="rgba(66,48,31,0.06)"/>
|
|
971
|
+
<text x="660" y="258" font-size="20" fill="#4b4036">Cache {escape(_fmt_percent(report.summary.cache_ratio))}</text>
|
|
972
|
+
|
|
973
|
+
<rect x="74" y="314" width="240" height="120" rx="24" fill="url(#teal)"/>
|
|
974
|
+
<text x="98" y="350" font-size="16" letter-spacing="2" fill="#d7faf5">TOKENS</text>
|
|
975
|
+
<text x="98" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.total_tokens:,}</text>
|
|
976
|
+
<text x="98" y="423" font-size="18" fill="#d7faf5">{report.summary.sessions} sessions</text>
|
|
977
|
+
|
|
978
|
+
<rect x="334" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
979
|
+
<text x="358" y="350" font-size="16" letter-spacing="2" fill="#6d645d">EST. COST</text>
|
|
980
|
+
<text x="358" y="397" font-size="40" font-weight="700" fill="#1f1a17">${report.summary.estimated_cost_usd:.2f}</text>
|
|
981
|
+
<text x="358" y="423" font-size="18" fill="#6d645d">Projected ${report.costs.projected_monthly_cost_usd:.2f}</text>
|
|
982
|
+
|
|
983
|
+
<rect x="574" y="314" width="220" height="120" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
984
|
+
<text x="598" y="350" font-size="16" letter-spacing="2" fill="#6d645d">REQUESTS</text>
|
|
985
|
+
<text x="598" y="397" font-size="40" font-weight="700" fill="#1f1a17">{report.summary.requests}</text>
|
|
986
|
+
<text x="598" y="423" font-size="18" fill="#6d645d">Largest {report.summary.largest_session_tokens:,}</text>
|
|
987
|
+
|
|
988
|
+
<rect x="814" y="314" width="312" height="120" rx="24" fill="url(#amber)"/>
|
|
989
|
+
<text x="838" y="350" font-size="16" letter-spacing="2" fill="#ffedd5">AVG / REQUEST</text>
|
|
990
|
+
<text x="838" y="397" font-size="40" font-weight="700" fill="#ffffff">{report.summary.average_tokens_per_request:,.0f}</text>
|
|
991
|
+
<text x="838" y="423" font-size="18" fill="#ffedd5">{escape(report.insights.suggestion[:34])}</text>
|
|
992
|
+
|
|
993
|
+
<rect x="74" y="466" width="492" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
994
|
+
<text x="98" y="500" font-size="18" letter-spacing="2" fill="#8a4b0f">ANOMALIES</text>
|
|
995
|
+
<text x="98" y="530" font-size="20" fill="#8a4b0f">• {escape(first_anomaly)}</text>
|
|
996
|
+
<text x="98" y="556" font-size="20" fill="#8a4b0f">• {escape(second_anomaly)}</text>
|
|
997
|
+
|
|
998
|
+
<rect x="592" y="466" width="534" height="94" rx="24" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
999
|
+
<text x="616" y="500" font-size="18" letter-spacing="2" fill="#4b4036">DO NEXT</text>
|
|
1000
|
+
<text x="616" y="530" font-size="20" fill="#4b4036">• {escape(first_action)}</text>
|
|
1001
|
+
<text x="616" y="556" font-size="20" fill="#4b4036">• {escape(second_action)}</text>
|
|
1002
|
+
|
|
1003
|
+
<text x="1128" y="590" text-anchor="end" font-size="18" fill="#6d645d">Generated by codex-stats</text>
|
|
1004
|
+
</svg>"""
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _wrap_chart_svg(title: str, subtitle: str, chart_svg: str) -> str:
|
|
1008
|
+
chart_body = chart_svg.strip()
|
|
1009
|
+
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="720" viewBox="0 0 1200 720" role="img" aria-label="{escape(title)}">
|
|
1010
|
+
<rect width="1200" height="720" fill="#f6efe4"/>
|
|
1011
|
+
<rect x="40" y="36" width="1120" height="648" rx="30" fill="#fffaf2" stroke="rgba(66,48,31,0.10)"/>
|
|
1012
|
+
<text x="72" y="94" font-size="18" letter-spacing="3" fill="#0f766e">CODEX STATS</text>
|
|
1013
|
+
<text x="72" y="148" font-size="42" font-weight="700" fill="#1f1a17">{escape(title)}</text>
|
|
1014
|
+
<text x="72" y="184" font-size="22" fill="#6d645d">{escape(subtitle)}</text>
|
|
1015
|
+
<g transform="translate(72 220)">{chart_body}</g>
|
|
1016
|
+
</svg>"""
|
|
1017
|
+
|
|
1018
|
+
|
|
717
1019
|
def format_watch_dashboard(
|
|
718
1020
|
summary: TimeSummary,
|
|
719
1021
|
compare: CompareReport,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-stats
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: Local AI usage observability CLI for Codex sessions.
|
|
5
5
|
Author: Vivek
|
|
6
6
|
License: MIT
|
|
@@ -108,13 +108,21 @@ python3 -m pip install codex-stats
|
|
|
108
108
|
- `codex-stats report weekly --format markdown`
|
|
109
109
|
Generate a weekly report in Markdown.
|
|
110
110
|
- `codex-stats report weekly --format html`
|
|
111
|
-
Generate a standalone HTML report for sharing.
|
|
111
|
+
Generate a standalone HTML report for sharing, including inline charts.
|
|
112
|
+
- `codex-stats report weekly --format svg`
|
|
113
|
+
Generate a bundle of smaller standalone SVG assets in the current working directory, including a summary card plus graph-focused assets.
|
|
112
114
|
- `codex-stats report weekly --project backend-api`
|
|
113
115
|
Generate a weekly report for one project.
|
|
114
116
|
- `codex-stats report weekly --format markdown --output weekly-report.md`
|
|
115
117
|
Write a formatted report to a file.
|
|
116
118
|
- `codex-stats report weekly --format html --output weekly-report.html`
|
|
117
119
|
Write a polished standalone HTML report to a file.
|
|
120
|
+
- `codex-stats report weekly --format svg --output weekly-report-assets`
|
|
121
|
+
Write the SVG asset bundle into the output directory you provide.
|
|
122
|
+
- `codex-stats report weekly --format html --render`
|
|
123
|
+
Generate an HTML report, write it to a temp file, and open it in the default browser.
|
|
124
|
+
- `codex-stats report weekly --format svg --render`
|
|
125
|
+
Generate an SVG report, write it to a temp file, and open it in the default browser.
|
|
118
126
|
- `codex-stats export codex-stats-export.json`
|
|
119
127
|
Export normalized local stats to JSON.
|
|
120
128
|
- `codex-stats export codex-stats-export.json --since 30d`
|
|
@@ -217,6 +225,10 @@ Then import the dashboard JSON into Grafana and point it at your Prometheus-comp
|
|
|
217
225
|
|
|
218
226
|
The current priority list lives in [docs/roadmap.md](/Users/vivek/Desktop/Salad/codex_stats/docs/roadmap.md).
|
|
219
227
|
|
|
228
|
+
## Shareable Assets
|
|
229
|
+
|
|
230
|
+
Share-ready assets now live under [docs/assets/codex-stats-share-card.svg](/Users/vivek/Desktop/Salad/codex_stats/docs/assets/codex-stats-share-card.svg), and `report --format svg` can generate a full bundle of report assets directly from local usage data.
|
|
231
|
+
|
|
220
232
|
## Watch Alerts
|
|
221
233
|
|
|
222
234
|
`watch` can surface live warnings and critical alerts directly in the terminal dashboard.
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
|
+
import tempfile
|
|
4
5
|
import unittest
|
|
5
6
|
from pathlib import Path
|
|
7
|
+
from unittest import mock
|
|
6
8
|
|
|
7
9
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
8
10
|
|
|
9
|
-
from codex_stats.cli import
|
|
11
|
+
from codex_stats.cli import (
|
|
12
|
+
_default_report_basename,
|
|
13
|
+
_default_report_filename,
|
|
14
|
+
_open_report_in_browser,
|
|
15
|
+
_write_report_output,
|
|
16
|
+
_write_report_svg_assets,
|
|
17
|
+
build_parser,
|
|
18
|
+
)
|
|
10
19
|
|
|
11
20
|
|
|
12
21
|
class CliTestCase(unittest.TestCase):
|
|
@@ -155,15 +164,16 @@ class CliTestCase(unittest.TestCase):
|
|
|
155
164
|
parser = build_parser()
|
|
156
165
|
init_args = parser.parse_args(["init", "--force"])
|
|
157
166
|
compare_args = parser.parse_args(["compare", "today", "yesterday"])
|
|
158
|
-
report_args = parser.parse_args(["report", "weekly", "--format", "
|
|
167
|
+
report_args = parser.parse_args(["report", "weekly", "--format", "svg", "--project", "project", "--output", "weekly.svg", "--render"])
|
|
159
168
|
self.assertEqual(init_args.command, "init")
|
|
160
169
|
self.assertTrue(init_args.force)
|
|
161
170
|
self.assertEqual(compare_args.current, "today")
|
|
162
171
|
self.assertEqual(compare_args.previous, "yesterday")
|
|
163
172
|
self.assertEqual(report_args.period, "weekly")
|
|
164
|
-
self.assertEqual(report_args.format, "
|
|
173
|
+
self.assertEqual(report_args.format, "svg")
|
|
165
174
|
self.assertEqual(report_args.project_name, "project")
|
|
166
|
-
self.assertEqual(report_args.output, "weekly.
|
|
175
|
+
self.assertEqual(report_args.output, "weekly.svg")
|
|
176
|
+
self.assertTrue(report_args.render)
|
|
167
177
|
|
|
168
178
|
def test_project_drilldown_parser(self) -> None:
|
|
169
179
|
parser = build_parser()
|
|
@@ -188,6 +198,30 @@ class CliTestCase(unittest.TestCase):
|
|
|
188
198
|
self.assertEqual(args.config_command, "show")
|
|
189
199
|
self.assertTrue(args.json_output)
|
|
190
200
|
|
|
201
|
+
def test_write_report_output_and_browser_open(self) -> None:
|
|
202
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
203
|
+
output_path = _write_report_output("<html></html>", str(Path(tmpdir) / "report.html"), suffix=".html")
|
|
204
|
+
self.assertTrue(output_path.exists())
|
|
205
|
+
self.assertIn("report.html", str(output_path))
|
|
206
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
207
|
+
svg_dir = _write_report_svg_assets(
|
|
208
|
+
{"summary-card": "<svg></svg>", "daily-tokens": "<svg></svg>"},
|
|
209
|
+
tmpdir,
|
|
210
|
+
base_name="codex-stats-weekly",
|
|
211
|
+
)
|
|
212
|
+
self.assertEqual(svg_dir, Path(tmpdir))
|
|
213
|
+
svg_output = Path(tmpdir) / "codex-stats-weekly-summary-card.svg"
|
|
214
|
+
self.assertTrue(svg_output.exists())
|
|
215
|
+
with mock.patch("codex_stats.cli.webbrowser.open") as open_mock:
|
|
216
|
+
_open_report_in_browser(svg_output)
|
|
217
|
+
open_mock.assert_called_once()
|
|
218
|
+
|
|
219
|
+
def test_default_report_filename(self) -> None:
|
|
220
|
+
self.assertEqual(_default_report_filename("weekly", None, "svg"), "codex-stats-weekly.svg")
|
|
221
|
+
self.assertEqual(_default_report_filename("monthly", "Backend API", "svg"), "codex-stats-monthly-backend-api.svg")
|
|
222
|
+
self.assertEqual(_default_report_basename("weekly", None), "codex-stats-weekly")
|
|
223
|
+
self.assertEqual(_default_report_basename("monthly", "Backend API"), "codex-stats-monthly-backend-api")
|
|
224
|
+
|
|
191
225
|
|
|
192
226
|
if __name__ == "__main__":
|
|
193
227
|
unittest.main()
|
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
12
12
|
|
|
13
13
|
from codex_stats.config import Paths
|
|
14
|
-
from codex_stats.display import format_report_html, format_watch_dashboard
|
|
14
|
+
from codex_stats.display import format_report_html, format_report_svg, format_report_svg_assets, format_watch_dashboard
|
|
15
15
|
from codex_stats.ingest import get_session, get_session_details
|
|
16
16
|
from codex_stats.metrics import (
|
|
17
17
|
apply_watch_state,
|
|
@@ -445,12 +445,28 @@ class MetricsTestCase(unittest.TestCase):
|
|
|
445
445
|
def test_report_html_output(self) -> None:
|
|
446
446
|
now = datetime.fromisoformat("2026-04-03T18:30:00+05:30")
|
|
447
447
|
report = build_report(self.paths, "weekly", now=now)
|
|
448
|
-
|
|
448
|
+
daily_points = summarize_daily(self.paths, days=7, now=now)
|
|
449
|
+
html = format_report_html(report, daily_points=daily_points)
|
|
449
450
|
self.assertIn("<!DOCTYPE html>", html)
|
|
450
451
|
self.assertIn("Codex Stats Weekly Report", html)
|
|
451
452
|
self.assertIn("Top Sessions", html)
|
|
452
453
|
self.assertIn("Top Projects", html)
|
|
453
454
|
self.assertIn("Generated by codex-stats", html)
|
|
455
|
+
self.assertIn("<svg", html)
|
|
456
|
+
self.assertIn("Daily Token Trend", html)
|
|
457
|
+
|
|
458
|
+
def test_report_svg_output(self) -> None:
|
|
459
|
+
now = datetime.fromisoformat("2026-04-03T18:30:00+05:30")
|
|
460
|
+
report = build_report(self.paths, "weekly", now=now)
|
|
461
|
+
daily_points = summarize_daily(self.paths, days=7, now=now)
|
|
462
|
+
svg = format_report_svg(report, daily_points=daily_points)
|
|
463
|
+
assets = format_report_svg_assets(report, daily_points=daily_points)
|
|
464
|
+
self.assertIn("<svg", svg)
|
|
465
|
+
self.assertIn("Compact share card for release notes, README embeds, and social posts.", svg)
|
|
466
|
+
self.assertIn("ANOMALIES", svg)
|
|
467
|
+
self.assertIn("Generated by codex-stats", svg)
|
|
468
|
+
self.assertEqual(set(assets), {"summary-card", "daily-tokens", "daily-cost", "project-share", "top-sessions"})
|
|
469
|
+
self.assertIn("Daily Token Trend", assets["daily-tokens"])
|
|
454
470
|
|
|
455
471
|
|
|
456
472
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|