diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..36fb095
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,39 @@
+---
+name: Bug Report
+about: Report a bug in Cronalytics
+title: "[Bug] "
+labels: bug
+assignees: ""
+---
+
+## Describe the bug
+
+A clear and concise description of what the bug is.
+
+## To Reproduce
+
+Steps to reproduce the behavior:
+
+1. Run `cronalytics ...`
+2. Open the dashboard and click ...
+3. See error
+
+## Expected behavior
+
+A clear and concise description of what you expected to happen.
+
+## What actually happened
+
+The error message, incorrect behavior, or unexpected output.
+
+## Environment
+
+- **Cronalytics version:** [e.g., 1.1.0]
+- **Hermes Agent version:** [e.g., 0.10.0]
+- **OS:** [e.g., Arch Linux, macOS 14]
+- **Browser (if dashboard issue):** [e.g., Firefox 126, Chrome 125]
+- **Install method:** [e.g., `hermes plugins install`, pip install]
+
+## Additional context
+
+Add any other context: screenshots, `facts.db` excerpts (no secrets!), relevant cron job definitions, or logs from `hermes logs --gateway`.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..267c077
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,27 @@
+---
+name: Feature Request
+about: Suggest an idea for Cronalytics
+title: "[Feature] "
+labels: enhancement
+assignees: ""
+---
+
+## Is your feature request related to a problem?
+
+A clear and concise description of what the problem is. Ex: "I can't see my cron jobs from a non-default profile."
+
+## Describe the solution you'd like
+
+A clear and concise description of what you want to happen.
+
+## Describe alternatives you've considered
+
+A clear and concise description of any alternative solutions or workarounds you've tried.
+
+## How would you use this?
+
+Describe the workflow or use case this feature would enable for you.
+
+## Additional context
+
+Add any other context, references, or screenshots.
\ No newline at end of file
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..c52fc37
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,22 @@
+## Description
+
+What does this PR do? Link the issue it fixes (e.g., `Fixes #123`).
+
+## Type of change
+
+- [ ] Bug fix (non-breaking change fixing an issue)
+- [ ] New feature (non-breaking change adding functionality)
+- [ ] Breaking change (fix or feature that would break existing behavior)
+- [ ] Documentation update
+- [ ] Chore (build, CI, tooling, cleanup)
+
+## Checklist
+
+- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md)
+- [ ] Tests pass: `python -m pytest tests/ -v --tb=short`
+- [ ] Lint clean: `ruff check .`
+- [ ] Type check clean: `mypy cronalytics/ dashboard/plugin_api.py`
+- [ ] Dashboard builds: `cd dashboard && node build.js`
+- [ ] Docs updated (if applicable): `docs/` or `dev/` files reflect the change
+- [ ] `CHECKPOINT.md` updated with decisions and verification notes (rebase will drop it)
+- [ ] I am targeting `master` (or `release/X.Y` for hotfixes)
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ff16d1b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,36 @@
+name: CI
+
+on:
+ push:
+ branches: [master, feat/*]
+ pull_request:
+ branches: [master]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install . ruff mypy pytest httpx pytest-github-actions-annotate-failures
+
+ - name: Lint with ruff
+ run: ruff check .
+
+ - name: Type check with mypy
+ run: mypy cronalytics/
+
+ - name: Test with pytest
+ run: pytest
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 5a33404..02cfb54 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,10 @@
# Python artifacts
__pycache__/
+*.venv/
+venv/
+ENV/
+.mypy_cache/
+.ruff_cache/
*.py[cod]
*$py.class
*.egg-info/
@@ -25,6 +30,13 @@ scratchpad/
# Auto-checkpoint state (generated at runtime)
cronalytics-checkpoint.json
CHECKPOINT.md
+
+# Internal planning docs — not for public repo
+PLAN.md
+AGENTS.md
+dev/AGENTS.md
+dev/LAUNCH_PLAN.md
+dev/LAUNCH_POSTS.md
# Test artifacts
.pytest_cache/
.coverage
@@ -38,8 +50,6 @@ htmlcov/
# Node artifacts (dashboard build)
node_modules/
+**/node_modules/
*.backup*
-*.bak
-facts.db-shm
-facts.db-wal
-CHECKPOINT.md
+*.bak
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f886792
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,72 @@
+# Changelog
+
+All notable changes to Cronalytics.
+
+---
+
+## v1.1.0 (2026-05-26)
+
+### Added
+
+- **Terminal CLI** — `cronalytics` command (via `pip install -e`) with 7 subcommands: `summary`, `jobs`, `runs`, `models`, `trends`, `health`, `all`. Full `--json` output on every data command except `all`. `--days`, `--outcome`, `--mode` filters across all commands. Leader Board spotlight in `summary`. Job name resolution from `jobs.json`.
+- **Agent Diagnostic Skill** — Built-in `cronalytics` skill with structured 7-step diagnostic workflow (time window verification → baseline health → job-level drill → per-run investigation → failure pattern → model economics → trend validation). Confidence-graded anomaly detection (HIGH / MEDIUM / LOW) with supporting evidence requirements. "Known Ways to Fool Yourself" guardrails (age-gating, script job awareness, variance checks). Cross-references `jobs.json` for scheduling context and silent failure detection.
+- **Test suite expanded to 149 tests** (83 original + 66 CLI tests) — all passing, `ruff` + `mypy` clean (note: `mypy` excludes `ingester.py`, `scanner.py`, `__init__.py`, and `dashboard/`; `disallow_untyped_defs = false`).
+- **Multilingual/Localization Support (i18n)**: coverage for [en, es, zh-CN, zh-TW]
+
+### Changed
+
+- **Package restructure** — Flat root modules moved into `cronalytics/` namespace package. Enables safe `pip install` without `site-packages` name collisions.
+- **CLI positioning** — CLI is now documented as an optional pip add-on to the dashboard plugin, not a standalone product. Requires the plugin's `facts.db` to function.
+- **Skill install** — No longer auto-linked by plugin; must be installed manually via `hermes skills install`.
+- **Trend Spikes:** Gated arrows behind 1.75x history window to prevent false alarms.
+- **UI Uniformity:** Consistent naming ("Avg Duration") and modernized icon-only refresh.
+
+---
+
+## v1.0.1 (2026-05-13)
+
+### Added
+
+- **Leader Board '% of total'** — spotlight cards show the leader's share of the window total (e.g. "42% of total cost")
+
+### Changed
+
+- **Cost card: suppressed Actual** — partial `actual_cost_usd` coverage creates misleading comparisons. The line now reads `Actual: —` until provider billing data coverage is reliable.
+
+### Fixed
+
+- **Backend fix** — synthetic script-only rows now insert `NULL` for `actual_cost_usd` (was 0.0), eliminating phantom `$0.00` aggregates.
+
+---
+
+## v1.0.0 (2026-05-12)
+
+### Added
+
+- Dashboard: Summary Board, Leader Board, Per-Model Breakdown, Jobs Breakdown table
+- Sortable 8-column jobs table with expandable detail rows
+- Job Detail Modal with full run history, sticky headers, inherited sorting
+- Outcome toggle (All/Success/Failure) with conditional Cost card colors
+- Mode toggle (All/Agent/No agent) with script job visibility
+- Pace, Nominal, and Trend projections with educational modals
+- Reconciliation scanner with watermark-based backfill
+- Bootstrap scanner on plugin load (catches post-restart gaps)
+- 83 pytest tests covering facts, parser, scanner, schedule, ingester, plugin API
+- Lint/type check: `ruff` + `mypy` clean
+- Keyboard-accessible cards and table headers (a11y)
+- Large-font theme resilience
+- API validation layer (JSDoc typedefs + runtime guards)
+
+---
+
+## v0.1.0
+
+### Added
+
+- Initial release: real-time ingestion, fact DB, reconciliation scanner, dashboard API, React frontend with summary cards, jobs table, cost-by-model, sync button.
+
+---
+
+*Plugin path: `~/.hermes/plugins/cronalytics/`*
+*Fact DB: `~/.hermes/plugins/cronalytics/facts.db`*
+*API base: `/api/plugins/cronalytics/`*
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..f89b6e7
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,41 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment:
+
+- Demonstrating empathy and kindness toward other people
+- Being respectful of differing opinions, viewpoints, and experiences
+- Giving and gracefully accepting constructive feedback
+- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+- Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior:
+
+- The use of sexualized language or imagery, and sexual attention or advances of any kind
+- Trolling, insulting or derogatory comments, and personal or political attacks
+- Public or private harassment
+- Publishing others' private information, such as a physical or email address, without their explicit permission
+- Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated promptly and fairly.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4332d3c..b44baf3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,12 +13,49 @@
3. Tag the release (e.g., `v1.0.1`)
4. Merge or cherry-pick the fix into `master` so it doesn't regress in the next release
+## How to Contribute
+
+1. **Fork the repo** and clone your fork.
+2. **Create a branch** — `feature/my-feature` or `fix/bug-description`.
+3. **Make your changes.** Keep commits focused and atomic.
+4. **Run the test suite:** `python -m pytest tests/ -v --tb=short`
+5. **Lint and type check:** `ruff check . && mypy cronalytics/ dashboard/plugin_api.py`
+6. **Build the dashboard:** `cd dashboard && node build.js`
+7. **Open a pull request** against `master` (or `release/X.Y` for hotfixes).
+
## Pull Requests
- All changes to `master` and `release/*` branches require PR review.
- `release/*` branches require at least 1 approving review.
- Keep commits focused and atomic. Squash fixups before merge if the branch is noisy.
+- Use the [Pull Request Template](.github/PULL_REQUEST_TEMPLATE.md) — it gives reviewers the context they need.
+
+## Reporting Bugs
+
+Please use the [Bug Report](https://github.com/8bit64k/cronalytics/issues/new?template=bug_report.md) template. A good bug report includes:
+
+- What you did (exact steps or commands)
+- What you expected to happen
+- What actually happened (error message, incorrect output)
+- Your environment (Cronalytics version, Hermes version, OS, browser)
+
+The more specific you are, the faster we can reproduce and fix it.
+
+## Feature Requests
+
+Use the [Feature Request](https://github.com/8bit64k/cronalytics/issues/new?template=feature_request.md) template. Tell us the problem you're trying to solve, not just the solution you want. Context helps us find the right fix.
## Development Environment
See [`dev/DEV_SETUP.md`](dev/DEV_SETUP.md) for build, test, and plugin setup instructions.
+
+## Code Review
+
+Reviewers will check for:
+
+- Tests pass and cover the change
+- Docs reflect new or changed behavior
+- Lint and type checks are clean
+- The change follows existing patterns and conventions in the codebase
+
+If your PR is large, open it as a draft and ask for early feedback. Small, incremental PRs review faster and merge sooner.
\ No newline at end of file
diff --git a/DOCS.md b/DOCS.md
new file mode 100644
index 0000000..662f668
--- /dev/null
+++ b/DOCS.md
@@ -0,0 +1,108 @@
+# Documentation Contract — Cronalytics
+
+This file defines what each document in this repository is **for**. No document may duplicate the content of another. If content appears in more than one doc, the non-canonical copy is a liability, not a convenience.
+
+## The Rules
+
+1. **Each document has exactly one role.** Read the contracts below before writing.
+2. **Content lives in one place.** If you need it in another place, link — don't copy.
+3. **When a feature changes, update exactly one doc** — the canonical one. Not two. Not three.
+4. **When in doubt, put it in the most specialized doc first.** General docs (README) link out; never absorb.
+
+---
+
+## Document Contracts
+
+### README.md
+**Role:** 90-second pitch. Why it exists, what it does at a glance, how to start.
+**Audience:** New users seeing the repo for the first time.
+**Contains:** Tagline, one-paragraph description, install jump links, mini-tour, "A Closer Look" feature overview, documentation index, license.
+**Does NOT contain:** Full feature walkthroughs, architecture details, API reference tables, data model schemas, changelog entries, CLI command references.
+
+### docs/USAGE.md
+**Role:** How a human reads the dashboard and runs CLI commands.
+**Audience:** Users who have installed Cronalytics.
+**Contains:** Dashboard layout walkthrough, toolbar controls, metric interpretation, CLI commands, agent skill usage, common workflows.
+**Does NOT contain:** Architecture decisions, design rationale, feature catalog, release history.
+
+### dev/FEATURES.md
+**Role:** Canonical feature catalog — what exists, what it does.
+**Audience:** Release reviewers, contributors evaluating scope.
+**Contains:** Exhaustive list of implemented features organized by subsystem, formulas, data sources.
+**Does NOT contain:** Usage instructions, architecture rationale, design decisions.
+
+### dev/DESIGN.md
+**Role:** Technical architecture, data flow, design decisions, positioning.
+**Audience:** Maintainers, contributors who need to understand why decisions were made.
+**Contains:** Problem/solution, architecture diagrams, technical decisions with rationale, data flow, boundaries, CLI design philosophy, file layout.
+**Does NOT contain:** Feature catalog, user guides, install instructions.
+
+### CHANGELOG.md
+**Role:** Version history — what changed, when.
+**Audience:** Anyone who wants to know what's new in a release.
+**Contains:** Per-version entries organized as Added/Changed/Fixed.
+**Does NOT contain:** Marketing release notes, usage details, architecture.
+
+### docs/RELEASE_NOTES.md
+**Role:** Release highlights — "what's new and why you should upgrade."
+**Audience:** Existing users deciding whether to upgrade.
+**Contains:** Per-release narrative, key features, upgrade guidance.
+**Does NOT contain:** Exhaustive changelog (links to CHANGELOG.md), architecture details.
+
+### docs/INSTALL.md
+**Role:** Installation instructions for new users.
+**Audience:** First-time installers.
+**Contains:** Setup steps, verification.
+**Does NOT contain:** Upgrade instructions (see UPGRADE.md), uninstall (see UNINSTALL.md).
+
+### docs/UPGRADE.md
+**Role:** Migration guide for existing users upgrading between versions.
+**Audience:** Users on an older version.
+**Contains:** Breaking changes, migration steps, verification.
+**Does NOT contain:** Fresh install instructions (see INSTALL.md).
+
+### docs/TROUBLESHOOTING.md
+**Role:** Common issues and fixes.
+**Audience:** Anyone encountering problems.
+**Contains:** Symptoms, causes, fixes. Link to relevant install/upgrade docs.
+
+### docs/UNINSTALL.md
+**Role:** Removal instructions.
+**Audience:** Users removing Cronalytics.
+
+### dev/BRIEF.md
+**Role:** Product opportunity and market positioning.
+**Audience:** Contributors and stakeholders evaluating the product's reason for existing.
+
+### dev/DEV_SETUP.md
+**Role:** Development environment setup for contributors.
+**Audience:** Developers who want to work on Cronalytics itself.
+
+### docs/GLOSSARY.md
+**Role:** Canonical technical terminology — prevents translation drift.
+**Audience:** Agents and translators.
+
+### docs/I18N_PROTOCOL.md
+**Role:** Multi-model translation consensus process.
+**Audience:** Agents and contributors adding new locales.
+
+### AGENTS.md
+**Role:** Rules for AI agents working on this repo.
+**Audience:** AI agents (not humans, not users).
+**Note:** Not tracked in git — local development only.
+
+### CHECKPOINT.md
+**Role:** Session log for multi-session development.
+**Audience:** The next agent that picks up where you left off.
+**Note:** Not tracked in git — local development only.
+
+---
+
+## Anti-Patterns
+
+These are the failure modes this contract exists to prevent:
+
+1. **"README should be self-contained"** — No. README is the front door, not the house. It points inward.
+2. **"I'll update both docs to be safe"** — This guarantees divergence. Update the canonical doc only.
+3. **"I'm not sure which doc, so I'll add it to all of them"** — Defer to the most specialized doc. If unsure, ask.
+4. **"A user might not click through, so I'll repeat it here"** — Trust the reader. Repetition breeds inconsistency.
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..7949098
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,9 @@
+prune .git
+prune tests
+prune dev
+prune skills
+prune dashboard
+prune docs
+exclude .gitignore
+exclude CHECKPOINT.md
+exclude PLAN.md
diff --git a/PLAN.md b/PLAN.md
deleted file mode 100644
index 2fda16a..0000000
--- a/PLAN.md
+++ /dev/null
@@ -1,114 +0,0 @@
-# Plan — Cronalytics
-
-> What remains for V1.0. Everything here is either **not started** or **partially done and needs completion**.
-
-This document replaces the previous phase-based plan with a flat priority-sorted task list. Items are pulled from DESIGN.md and FEATURES.md only if they are genuinely not implemented.
-
----
-
-## 🟢 High (Significant User Value)
-
-All high-priority technical items are **delivered** for V1.0.
-
-| # | Task | Current State |
-|---|------|---------------|
-| H1 | **Success/failure cost split** | ✅ Backend: `success_runs`, `failure_runs`, `success_cost`, `failure_cost` on `/summary` and `/jobs`. Frontend: Cost card shows ✓/✗ counts + wasted cost; expandable job detail rows show per-job ✓/✗ breakdown. |
-| H2 | **Per-job token columns in jobs table** | ✅ Token breakdown shown in expandable detail row below each job. Header-level token columns rejected to avoid horizontal overflow. |
-| H3 | **Sortable jobs table** | ✅ All 8 columns sortable ↑/↓ with direction indicator. |
-| H4 | **Top jobs highlight** | ✅ Leader Board: 4 cards (Top Runs, Top Cost, Top Tokens, Top Pace) with icon accents and mono job names. |
-| H5 | **Per-run expansion in dashboard UI** | ✅ Modal-based job detail view with inline row expansion, sortable run table, sticky headers, 95% width, 200-run limit. |
-| H6 | **Duration metrics** | ✅ Backend: `total_duration_seconds` + `avg_duration_seconds` in `/summary`; `total_duration` + `avg_duration` in `/jobs`. Frontend: `Avg Time` column in Jobs Breakdown (sortable); avg duration in Top Runs + Top Cost modals. |
-| H7 | **Suspect/hung job detection** | ⚫ Deferred to v1.1+. Low surface area, troubleshooting-oriented rather than cost/visibility core. |
-| H8 | **Global outcome toggle** | ✅ Toolbar toggle (All/Success/Failure). Inherits into job detail modal. Cost card conditional headline/color. LocalStorage persistence. |
-| H9 | **Agent / no_agent mode awareness** | ✅ Schema, sync, API params, toolbar badge+filter+footnote. Script scanning from output directory. `[No agent]` badges. |
-
----
-
-## 🟡 Medium (Quality of Life)
-
-| # | Task | Current State |
-|---|------|---------------|
-| M1 | **README** | ✅ Rewritten for V1.0. |
-| M2 | **CHANGELOG** | ✅ In README; standalone CHANGELOG.md deferred to v1.1. |
-| M3 | **Test suite** | ✅ Delivered — 83 tests covering facts, parser, scanner, schedule, ingester, plugin API. All passing. |
-| M4 | **Lint / type check** | ✅ Delivered — ruff + mypy clean on source files; `pyproject.toml` configured. |
-| M5 | **Periodic auto-sync** | ⚫ Deferred to v1.1. Bootstrap scanner + hook + retry cover steady-state and restart gaps. |
-| M6 | **iPad + theme compatibility pass** | ✅ Silver summary icons, mono sub-lines, neutral token bars, Leader Board titles default color, height parity, large-font theme resilience. |
-| M7 | **Educational modals** | ✅ Delivered — Pace, Runs, Cost, Tokens modals with formulas and color guides. |
-| M8 | **Success/failure split for wrapper vs payload** | ✅ Documented in README under "Understanding your data". |
-| M9 | **Collapsible hero banner** | ✅ Delivered — expand/collapse toggle with localStorage persistence. Reclaims ~3.5 lines of vertical space. |
-
----
-
-## ⚫ Deferred (Post-V1.0)
-
-| # | Task | Why Deferred |
-|---|------|--------------|
-| D1 | **Budget thresholds with alerts** | Needs a notification system (Telegram, email) that Cronalytics does not own. |
-| D2 | **Model comparison recommendations** | "Switch from Opus to Sonnet and save $X" requires stable pricing data and a recommendation engine. Out of scope. |
-| D3 | **Schedule optimization** | "Runs every 5 min but produces output 10% of the time" requires analyzing session outputs, which we deliberately do not store. |
-| D4 | **Tool-level cost attribution** | Would require joining with `session_messages`, which is large and not cached in the fact DB. |
-| D5 | **Live log streaming** | Output files live at `~/.hermes/cron/output/`. Streaming them into the dashboard is a separate infrastructure project. |
-| D6 | **External DB backend** | PostgreSQL, TimescaleDB, etc. SQLite is sufficient for single-user local usage. |
-| D7 | **Job detail modal pagination** | Modal limits to 200 runs (default API `limit=200`). High-frequency jobs show correct run count in breakdown, but drill-down is capped. Needs "Load more" or pagination toggle. |
-| D8 | **Focus trap in modals** | Medium effort with moderate DOM edge-case risk in Hermes plugin context. Escape/backdrop already work. |
-
----
-
-## V1.1 / vNext Backlog
-
-Items to be delivered on the `vNext` branch and merged when ready.
-
-| # | Task | Status | Notes |
-|---|------|--------|-------|
-| V1 | **Leader Board "% of total"** | ✅ Merged to vNext | Top Runs, Top Cost, Top Tokens cards show leader's share of the aggregate (e.g. "83% of all runs"). Replaces the empty `3rem` spacer. |
-| V2 | **Summary Board token trend** | Pending | Deferred until a non-redundant metric is identified. Raw token count correlates with run count. Candidate: tokens-per-run trend. Needs Tokens card space analysis first. |
-| V3 | **Docs audience separation** | ✅ Merged | Move dev-only docs to `dev/`; keep user docs in `docs/`. Separate concerns. |
-
----
-
-## Upstream Signals — Feature Validation
-
-Tracked from `NousResearch/hermes-agent` issues and PRs. These confirm the problem space Cronalytics occupies and identify gaps to fill.
-
-| # | Author | What They Asked | Cronalytics Today | Gap / Opportunity |
-|---|--------|-----------------|-------------------|-------------------|
-| #23419 | nvst18 | Cron jobs burn provider credits without cost visibility or budget cap | Cost surfaced per run and per job; no budget enforcement | Budget alerting / spend ceiling |
-| #20622 | arshad2k | Multi-profile cron jobs hidden from centralized monitoring | Default-profile only; non-default jobs invisible | Cross-profile scanner or hook propagation |
-| #20412 | sanchomuzax | Separate Input/Output token charts + per-model breakdown | Model-level cost bars; no I/O token split | Input vs. Output token visualization |
-| #24258 | Freffles | Model and provider attribution missing from cron UI | Model shown in job table; provider not surfaced | Provider column / filter |
-| #6642 | xinbenlv | Unified telemetry for latency, cost, completion/failure rates | Cron-specific facts; latency tracked per session only | Expand beyond cron? Aggregate across all agent dispatches? |
-
----
-
-## V1.0 Launch Status
-
-**Feature freeze: May 14, 2026** ✅ Complete.
-**Launch date: May 19, 2026** — Packaging phase active.
-
-### Remaining before launch
-- [x] All technical features delivered and merged to master
-- [x] Test suite: 83 tests passing
-- [x] Lint/type: ruff + mypy clean
-- [x] README rewritten
-- [x] DESIGN.md rewritten
-- [x] FEATURES.md rewritten
-- [x] INSTALL.md updated
-- [x] UNINSTALL.md created
-- [x] USAGE.md created
-- [x] X thread draft — written
-- [x] Discord announcement draft — written
-- [x] YouTube video description draft — written
-- [ ] Demo video / GIF (May 16)
-- [x] GitHub release — tag v1.0.0 (May 12, accelerated)
-- [x] Demo video / GIF — `docs/screenshots/cronalytics-tour.gif`
-- [x] X thread draft — written (7 tweets, in dev/LAUNCH_POSTS.md)
-- [x] Discord announcement draft — written (in dev/LAUNCH_POSTS.md)
-- [ ] X thread — posted
-- [ ] Discord announcement — posted
-- [ ] Final cross-device / cross-theme pass
-
----
-
-*Version: 1.0.0*
-*Last updated: 2026-05-11 (night session)*
diff --git a/README.md b/README.md
index 369cacc..218141a 100644
--- a/README.md
+++ b/README.md
@@ -1,317 +1,338 @@
# Cronalytics
-
-
/ˈkrɒn.əˌlɪt.ɪks/ (noun)
1. Cron analytics and observability.
2. The dashboard for agentic automations in Hermes.
Observe. Measure. Optimize.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Cronalytics is a Hermes Agent plugin that attributes session-level usage and estimated cost to every cron-originated run, so you can see what your scheduled jobs are costing you. It hooks into `on_session_end`, stores derived analytics in a local SQLite fact database, and surfaces them through **three** complementary interfaces: a hermes dashboard tab, a CLI tool for programmatic access, and an agent skill for cron health/diagnostics and comprehensive assessments. The current version is v1.1.0.
-
-Cronalytics is a Hermes Agent plugin that attributes session-level usage and estimated cost to every cron-originated run, so you can see what your scheduled jobs are costing you. It hooks into `on_session_end`, stores derived analytics in a local SQLite fact database, and surfaces them in the Hermes dashboard via a dedicated `/cronalytics` tab.
-
-> Turn hidden automation into visible spend.
->
> Built for **[Hermes Agent](https://github.com/nousresearch/hermes-agent)**, the autonomous agent framework by **[Nous Research](https://nousresearch.com)**.
---
-## Mini-Tour
-
+## Getting Started
-[YouTube](https://youtu.be/nbeViSt9hCk?si=EH2u7Ys2vDTVDqka): short video showing basic install and usage.
+| I am... | Path |
+| :--- | :--- |
+| **A New User** | [Install Guide (Fresh Start)](docs/INSTALL.md) |
+| **An Existing v1.0.x User** | [Upgrade Guide (v1.1 Migration)](docs/UPGRADE.md) |
+| **Exploring Features** | [Usage & Workflows](docs/USAGE.md) or [Feature Catalog](dev/FEATURES.md) |
---
-## What It Does
+## Quick Start and Usage
-- **Captures** every cron job run as it completes via the `on_session_end` hook
-- **Persists** cost, token counts, model, duration, and success state to a local fact database
-- **Backfills** historical data automatically on plugin load and on demand via reconciliation scanner
-- **Surfaces** a dashboard with:
- - Summary cards (total runs, estimated cost, tokens, pace)
- - Leader board (top runs, top cost, top tokens, top pace)
- - Cost-by-model breakdown with proportional bars
- - Per-job table with runs, cost, duration, projections, and sortable columns
- - Expandable detail rows showing token breakdown, schedule, and success/failure split
- - Job detail modal with full run history (sortable, 200-run limit)
- - Outcome filter (All / Success / Failure) with conditional card colors
- - Mode filter (All / Agent / No agent) for script-only job visibility
- - **Sync Now** button to trigger backfill on demand
- - Educational modals explaining Pace, Nominal, Trend, and cost math
+> The dashboard is insightful, but the CLI + Skill are the real superpower.
----
+#### **Dashboard — *Observe***
+Use the dedicated `/cronalytics` tab inside `hermes dashboard` for visual exploration. Charts, cards, and filters let you see cost, pace, and failure patterns at a glance.
-## Documentation Index
+#### **CLI — *Measure***
+Use the terminal tool for programmatic access, `--json` output, and agent consumption. Precise queries, exact numbers, and scriptable data exports. Features eight commands, three global filters, and json output.
-### User Documentation (`docs/`)
-
-- **docs/INSTALL.md** — Detailed installation guide
-- **docs/UNINSTALL.md** — Clean removal instructions
-- **docs/USAGE.md** — Dashboard usage guide
-
-### Developer Documentation (`dev/`)
-
-- **dev/BRIEF.md** — Product opportunity brief & positioning
-- **dev/DESIGN.md** — Architecture, data flow, and technical decisions
-- **dev/FEATURES.md** — Complete feature catalog with formulas
-- **dev/LAUNCH_PLAN.md** — V1.0 launch timeline
-- **dev/AGENTS.md** — Contributor conventions & release gates
-- **dev/DEV_SETUP.md** — Development environment setup
-- **PLAN.md** — Phased build plan and backlog (root)
-
----
+```
+usage: cronalytics [-h] [--db DB] [--days DAYS] [--outcome {all,success,failure}]
+ [--mode {all,agent,no_agent}] [--json]
+ {summary,jobs,models,trends,health,runs,all,sync} ...
+
+Cronalytics CLI — dump cron run insights to the terminal
+
+positional arguments:
+ {summary,jobs,models,trends,health,runs,all,sync}
+ summary Aggregate headline summary
+ jobs Per-job breakdown with pace
+ models Per-model estimated cost breakdown
+ trends Daily run-count / estimated cost sparkline
+ health Fact DB health check
+ runs Individual runs for a job
+ all Run health + summary + jobs + models + trends
+ sync Backfill cron sessions from state.db into fact DB
+
+options:
+ -h, --help show this help message and exit
+ --db DB Path to fact DB (default: auto-detected from plugin directory)
+ --days DAYS Number of days to look back (default: 30, 0 = all time)
+ --outcome {all,success,failure}
+ Filter by outcome (default: all)
+ --mode {all,agent,no_agent}
+ Filter by job mode (default: all)
+ --json Output raw JSON instead of formatted tables
+ ```
+
+#### **Agent Skill — *Optimize***
+A built-in diagnostic skill that teaches Hermes agents how to analyze your cron jobs with confidence-graded anomaly detection and ranked recommendations. Ask your agent:
+
+> "Check my cron jobs for the last two weeks — flag anything that looks off."
+
+The agent loads the `cronalytics` skill, follows a structured 7-step diagnostic workflow (time window verification → baseline → job-level drill → per-run investigation → failure pattern → model economics → trend validation), cross-references `jobs.json`, and grades every finding by confidence (HIGH / MEDIUM / LOW) with supporting evidence and alternative explanations.
+
+### First-Time Setup
-## ⚠️ Important Notes
+After install, the plugin needs data:
-**Cost data is estimated, not exact.** Cronalytics reports the estimated cost that Hermes computed and stored in `state.db`. Your actual invoice may differ due to rate changes, credits, or rounding. Use this for directional awareness, not accounting.
+1. **Wait for a cron job to run** — the `on_session_end` hook captures it automatically.
+2. **Or trigger a manual backfill** — click **Sync Now** in the dashboard.
-**Single-profile cron by default.** Cronalytics monitors the Hermes profile where it is installed. Most users — even those with multiple profiles configured — run cron jobs in the **default** profile. For them, Cronalytics works fully.
+If the dashboard shows "No cron jobs captured," click **Sync Now**.
-The edge case: if you explicitly create a cron job under a non-default profile (`hermes --profile cron create ...`), that job runs in an isolated gateway with its own `state.db`. Cronalytics, installed in the default profile, cannot see it. To monitor those jobs, install Cronalytics in that profile's `plugins/` directory as well.
+See **[FAQ: Visibility & Data](docs/FAQ.md)** for more help — how data updates, how far back you can look, and the 250-run modal limit.
-Multi-profile cron support is on our roadmap.
+For full details about usage and common workflows see **[USAGE.md](docs/USAGE.md)**.
---
-## Installation
-
-### Dashboard Plugins Tab (Recommended)
-
-Open the Hermes dashboard, navigate to the **Plugins** tab, and use the **Install from GitHub / Git URL** field. Enter:
-
-- `owner/repo` shorthand (e.g. `8bit64k/cronalytics`)
-- Or a full `https://` or `git@` clone URL
+## Features
+
+### Mini-Tour
+
+
+
+  |
+  |
+  |
+
+
+  |
+  |
+  |
+
+
+  |
+  |
+  |
+
+
+  |
+  |
+  |
+
+
-Check **Enable after install**, then click **Install**.
-
-### After Install
-
-Hard-refresh your browser (`Ctrl+Shift+R` or `Cmd+Shift+R`) to clear cached JS.
+---
-Open the **Cronalytics** tab in the dashboard sidebar.
+### What Cronalytics Does
-> **Reverse proxy users:** If you run the Hermes dashboard behind Caddy or Nginx, ensure `/api/*` routes are forwarded directly to the dashboard backend. A misconfigured proxy will return HTML instead of JSON for plugin API calls. See [`docs/INSTALL.md`](docs/INSTALL.md#reverse-proxy-setup) for a minimal Caddy example.
->
-> For development setup, see [`dev/DEV_SETUP.md`](dev/DEV_SETUP.md).
+- **Captures** every cron job run as it completes via the `on_session_end` hook
+- **Persists** cost, token counts, model, duration, and success state to a local fact database
+- **Backfills** historical data automatically on plugin load and on demand via reconciliation scanner
+- **Surfaces** data through three interfaces:
+
+### Dashboard — visual exploration:
+- Summary cards (total runs, estimated cost, tokens, pace)
+- Leader board (top runs, top cost, top tokens, top pace)
+- Cost-by-model breakdown with proportional bars
+- Per-job table with runs, cost, duration, projections, and sortable columns
+- Expandable detail rows showing token breakdown, schedule, and success/failure split
+- Job detail modal with full run history (sortable, 200-run limit)
+- Educational modals explaining Pace, Nominal, Trend, and cost math
+- Outcome filter (All / Success / Failure) with conditional card colors
+- Mode filter (All / Agent / No agent) for script-only job visibility
+- Day selector `7D | 30D | 90D` presets + custom input (0–365 days, Enter/Go)
+- Refresh — re-fetches summary and jobs
+- Sync Now button to trigger backfill on demand
+
+**Multi-Locale Support**
+Cronalytics implements a self-hosted internationalization layer for independent Hermes plugins. All UI elements, educational explainers, and metrics are fully localized for:
+- 🇺🇸 **English** (Source of Truth)
+- 🇪🇸 **Spanish** (Professional/Technical)
+- 🇨🇳 **Chinese Simplified** (zh-CN)
+- 🇹🇼 **Chinese Traditional** (zh-TW)
+
+### CLI — terminal access:
+- `summary` — headline aggregates + leader board + cost-by-model table
+- `jobs` — per-job table with ID, runs, cost, tokens, pace, avg duration
+- `runs --job ` — individual run history (time, duration, cost, tokens, model)
+- `models` — per-model aggregate table
+- `trends` — daily bar chart (ASCII) of cost + runs
+- `health` — fact DB metadata, job count, last sync
+- `all` — chains health → summary → jobs → models → trends
+- All commands support `--days N`, `--outcome`, and `--mode`; every data command except `all` supports `--json`
+- Job name resolution from `~/.hermes/cron/jobs.json`
+
+### Agent Skill — agent-guided diagnostics:
+- Structured 7-step workflow: time window verification → baseline → job-level drill → per-run investigation → failure pattern → model economics → trend validation
+- Confidence-graded anomaly detection (HIGH / MEDIUM / LOW)
+- `jobs.json` cross-reference for temporal context and silent failure detection
+- "Known Ways to Fool Yourself" guardrails prevent false positives
+- Works in any terminal session or messaging channel
+
+To explore the complete feature catalog see **[FEATURES.md](dev/FEATURES.md)**.
---
-## First-Time Setup
-
-After install, the plugin needs data:
+## ⚠️ Important Notes
-1. **Wait for a cron job to run** — the `on_session_end` hook captures it automatically.
-2. **Or trigger a manual backfill** — click **Sync Now** in the dashboard, or run:
+### **Cost data is estimated, not exact.**
-```bash
-curl -H "X-Hermes-Session-Token: " -X POST http://localhost:9119/api/plugins/cronalytics/sync
-```
+Cronalytics reports the estimated cost that Hermes computed and stored in `state.db`. Your actual invoice may differ due to rate changes, credits, or rounding. Use this for directional awareness, not accounting.
-If the dashboard shows "No cron jobs captured," click **Sync Now**.
+> See **[FAQ: Cost & Billing](docs/FAQ.md)** — $0.00 costs, estimated vs actual, and why Cronalytics differs from your provider invoice.
-> **Note:** The sync endpoint requires the dashboard's ephemeral session token for security (injected into the SPA at startup). Most users should use the dashboard **Sync Now** button instead of curl.
+### Understanding Success
----
+**Cronalytics tracks two different notions of "success"**:
-## Configuration
+| Signal | What It Means | Source |
+|--------|--------------|--------|
+| **Wrapper Success** (`success` toggle in dashboard) | The cron wrapper finished without error — the job ran, the agent responded, and the wrapper exited cleanly. | `end_reason` field |
+| **Payload Success** | The agent's actual output was correct, useful, or achieved the intended goal. | **Not tracked** |
-### `plugin.yaml`
+### How to interpret the dashboard
-```yaml
-name: cronalytics
-version: 1.0.0
-description: Cost and operational observability for Hermes cron jobs
-provides_hooks:
- - on_session_end
-```
+- **Success = high, Failure = low** → Your cron jobs are mechanically reliable.
+- **Success = high, but output quality is poor** → The infrastructure is fine; the issue is in the prompt, model choice, or task definition.
+- **Failure = high** → Investigate timeouts, API errors, or wrapper crashes.
-### `config.py` (static defaults)
+> The Success/Failure toggle is a **reliability** signal, not a **correctness** signal.
-All current settings are hardcoded defaults. There is no user-editable config file yet (planned for v1.1).
+See **[FAQ: Metrics & Interpretation](docs/FAQ.md)** for Success vs Failure, agent vs no-agent jobs, and what Pace really means.
-| Setting | Default | Meaning |
-|---------|---------|---------|
-| `RETRY_DELAYS` | `[3.0, 8.0, 15.0]` | Seconds to wait before each worker retry |
-| `JITTER_MAX` | `2.0` | Max random seconds added to each retry delay |
-| `MAX_RETRIES` | `3` | Total attempts to read a session from `state.db` |
+### **Single-profile cron by default.**
-Paths are resolved automatically:
-- `STATE_DB`: `~/.hermes/state.db` (Hermes core session store)
-- `FACT_DB`: `~/.hermes/plugins/cronalytics/facts.db` (plugin-owned SQLite)
-- `WATERMARK_FILE`: `~/.hermes/plugins/cronalytics/watermark.json`
-- `PENDING_FILE`: `~/.hermes/plugins/cronalytics/pending.jsonl`
+Cronalytics monitors the Hermes profile where it is installed. Most users — even those with multiple profiles configured — run cron jobs in the **default** profile. For them, Cronalytics works fully.
----
+The edge case: if you explicitly create a cron job under a non-default profile (`hermes --profile cron create ...`), that job runs in an isolated gateway with its own `state.db`. Cronalytics, installed in the default profile, cannot see it.
-## What the Dashboard Shows
+Multi-profile cron support is on our roadmap.
-### Summary Board (Row 1)
+See **[FAQ: Where are my jobs?](docs/FAQ.md)** for a checklist of common reasons jobs don't appear.
-Four cards showing aggregate metrics for the selected window:
+---
-- **Job Runs** — total executions with vs-prior-period delta (↑/↓ %)
-- **Cost** — total estimated cost in amber; vs-prior delta + ✓/✗ breakdown
- (Actual cost placeholder suppressed — partial coverage creates misleading comparisons)
-- **Tokens** — total tokens in blue; In/Out/Cached proportion micro-bars
-- **Pace** — aggregate `trend_monthly / nominal_monthly` as a multiplier:
- - `< 1.0×` green — under scheduled budget
- - `1.0–2.0×` neutral — on track
- - `≥ 2.0×` red — over budget
+## Documentation Index
-Click any card to open an educational modal explaining the metric.
+### User Documentation (`docs/`)
-### Leader Board (Row 2)
+- **[INSTALL.md](docs/INSTALL.md)** — Installation guide (dashboard plugin + pip CLI + skill setup)
+- **[UPGRADE.md](docs/UPGRADE.md)** — Transition guide for v1.0.x users (Namespace restructure)
+- **[UNINSTALL.md](docs/UNINSTALL.md)** — Clean removal instructions
+- **[USAGE.md](docs/USAGE.md)** — Dashboard and CLI usage guide
+- **[TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** — Common issues and fixes
+- **[RELEASE_NOTES.md](docs/RELEASE_NOTES.md)** — Per-release upgrade notes and highlights
+- **[FAQ.md](docs/FAQ.md)** — Common questions and quick answers
-Four spotlight cards surfacing the highest-value job in each dimension, with the leader's share of the window total:
+### Developer Documentation (`dev/`)
-- **Top Runs** — highest execution count; `% of total runs` sub-line
-- **Top Cost** — highest cumulative spend; `% of total cost` sub-line
-- **Top Tokens** — highest token consumption; `% of total tokens` sub-line
-- **Top Pace** — highest pace multiplier (most at risk of exceeding budget)
+- **[BRIEF.md](dev/BRIEF.md)** — Product opportunity brief & positioning
+- **[DESIGN.md](dev/DESIGN.md)** — Architecture, data flow, and technical decisions
+- **[FEATURES.md](dev/FEATURES.md)** — Complete feature catalog with formulas
+- **[DEV_SETUP.md](dev/DEV_SETUP.md)** — Development environment setup
-Click any card to open a detail modal with job metadata.
+### Project Meta
-### Per-Model Breakdown
+- **[CHANGELOG.md](CHANGELOG.md)** — Full version history
-Proportional bar chart showing the top 5 models by cost, with run counts. Remaining models collapsed with "and N more."
+---
-### Jobs Breakdown Table
+## Architecture at a Glance
-Eight sortable columns: **Job**, **Runs**, **Avg Time**, **Total Cost**, **Avg Cost**, **Nominal/mo**, **Trend/mo**, **Pace**.
+Cronalytics hooks into Hermes's `on_session_end`, enqueues session IDs, queries `state.db`, and stores derived analytics in a plugin-owned `facts.db`. Three interfaces read from that database: Dashboard (HTTP API), CLI (direct SQLite queries), and Agent Skill (CLI-piped heuristics).
-- Click a column header to sort ascending/descending
-- Click any row to expand a detail panel showing:
- - Token breakdown (total, in, out, cached)
- - Success/failure split with cost attribution
- - Schedule display, last run, model, next run
- - **See Runs** button opening a full modal
+For the full architecture diagram, data flow, and technical decisions, see **[DESIGN.md](dev/DESIGN.md#3-architecture)**.
-### Job Detail Modal
+---
-Full run history for the selected job:
-- 95% width modal with sticky headers
-- Sortable by run time, cost, duration, success, model
-- 200-run default limit (backend ceiling: 500)
-- Mode column showing Agent vs No agent
+## API Endpoints
-### Toolbar Controls
+All endpoints are mounted at `/api/plugins/cronalytics/`. Core endpoints: `GET /health`, `GET /summary`, `GET /jobs`, `GET /jobs/{job_id}/runs`, `GET /models`, `GET /trends`, `POST /sync`.
-- **Outcome toggle** — `All | Success | Failure` (persists in localStorage)
-- **Mode toggle** — `All | Agent | No agent` (persists in localStorage)
-- **Day selector** — `7D | 30D | 90D` presets + custom input (0–365 days, Enter/Go)
-- **Refresh** — re-fetches summary and jobs
-- **Sync Now** — triggers reconciliation scan with spinner + completion toast
+For the full endpoint table with parameters and response shapes, see **[DESIGN.md §4.11 API Validation Layer](dev/DESIGN.md#411-api-validation-layer)**.
---
-## Understanding Success
-
-Cronalytics tracks two different notions of "success":
-
-| Signal | What It Means | Source |
-|--------|--------------|--------|
-| **Wrapper Success** (`success` toggle in dashboard) | The cron wrapper finished without error — the job ran, the agent responded, and the wrapper exited cleanly. | `end_reason` field |
-| **Payload Success** | The agent's actual output was correct, useful, or achieved the intended goal. | **Not tracked** |
-
-### How to interpret the dashboard
+## Data Model
-- **Success = high, Failure = low** → Your cron jobs are mechanically reliable.
-- **Success = high, but output quality is poor** → The infrastructure is fine; the issue is in the prompt, model choice, or task definition.
-- **Failure = high** → Investigate timeouts, API errors, or wrapper crashes.
+The fact database (`facts.db`) is append-only — rows are inserted once and never updated or deleted. Core fields include `session_id`, `job_id`, `estimated_cost_usd`, `actual_cost_usd`, `model`, token breakdowns, `duration_seconds`, `end_reason`, and `success`.
-> The Success/Failure toggle is a **reliability** signal, not a **correctness** signal.
+For the full schema with field descriptions, see **[DESIGN.md §4.3 Fact DB](dev/DESIGN.md#43-fact-db-plugin-owned-append-only-sqlite)**.
---
-## Architecture at a Glance
+## File Layout
```
-Cron Job Due
- │
- ▼
-run_job() ──▶ agent.run_conversation()
- │
- ▼
-Hook: on_session_end(platform="cron")
- │
- ▼
-Enqueue session_id ──▶ Deferred worker retries
- │ (waits for DB flush)
- ▼
-Query state.db ──▶ Insert into facts.db
- │
- ▼
-Dashboard queries facts.db via plugin API
+cronalytics/
+├─── plugin.yaml # Plugin manifest (hooks, version)
+├─── __init__.py # Register hook + bootstrap scanner
+├─── cronalytics/ # Core package
+│ ├── cli.py # Terminal interface (entry point)
+│ ├── config.py # Paths + defaults
+│ ├── facts.py # SQLite fact DB: schema, insert, queries
+│ ├── ingester.py # Deferred ingestion worker + crash recovery
+│ ├── scanner.py # Reconciliation scanner + watermark I/O
+│ ├── schedule.py # Cron parsing + projection math
+│ ├── logger.py # Shared logger
+│ └── checkpoint.py # Session state persistence
+├─── skills/
+│ └─── devops/
+│ └─── cronalytics/
+│ └─── SKILL.md # Built-in diagnostic skill for agents
+├─── dashboard/
+│ ├─── manifest.json # Slot registration + routes
+│ ├─── plugin_api.py # REST API mounted at /api/plugins/cronalytics/
+│ ├─── build.js # esbuild bundler script
+│ ├─── src/ # Modular frontend source
+│ └─── dist/
+│ └─── index.js # Bundled React frontend
+└─── tests/ # Unit tests (run with pytest)
```
---
-## API Endpoints
-
-All endpoints are mounted at `/api/plugins/cronalytics/`.
-
-| Endpoint | Method | Description |
-|----------|--------|-------------|
-| `/health` | `GET` | Plugin health + sync metadata |
-| `/summary?days=N&outcome=both&mode=all` | `GET` | Aggregated totals with projections |
-| `/jobs?days=N&outcome=both&mode=all` | `GET` | Per-job rolled-up stats with projections |
-| `/jobs/{job_id}/runs` | `GET` | Individual runs for a specific job |
-| `/models?days=N&outcome=both&mode=all` | `GET` | Cost breakdown by model |
-| `/trends?days=N&outcome=both&mode=all` | `GET` | Daily cost + runs time series |
-| `/sync` | `POST` | Run reconciliation scanner manually |
-
----
-
-## Data Model
+## Configuration
-The fact database (`facts.db`) is append-only. Rows are inserted once and never updated or deleted.
+### `plugin.yaml`
-Key fields captured per run:
+```yaml
+name: cronalytics
+version: 1.1.0
+description: Cost and operational observability for Hermes cron jobs
+provides_hooks:
+ - on_session_end
+```
-- `session_id` — unique run key
-- `job_id` — stable job definition ID
-- `run_time` / `ended_at` / `duration_seconds`
-- `model`
-- `input_tokens` / `output_tokens` / `reasoning_tokens` / `cache_read_tokens` / `cache_write_tokens`
-- `estimated_cost_usd` — primary cost metric
-- `actual_cost_usd` — ground-truth when available
-- `cost_status`, `cost_source`, `billing_provider`
-- `api_call_count`, `message_count`, `tool_call_count`
-- `end_reason`, `success`
-- `job_mode` — `agent` or `no_agent`
-- `ingested_at`
+### `config.py` (static defaults)
----
+All current settings are hardcoded defaults. There is no user-editable config file yet (planned for a future release).
-## File Layout
+| Setting | Default | Meaning |
+|---------|---------|---------|
+| `RETRY_DELAYS` | `[3.0, 8.0, 15.0]` | Seconds to wait before each worker retry |
+| `JITTER_MAX` | `2.0` | Max random seconds added to each retry delay |
+| `MAX_RETRIES` | `3` | Total attempts to read a session from `state.db` |
-```
-cronalytics/
-├── plugin.yaml # Plugin manifest (hooks, version)
-├── __init__.py # Register hook + bootstrap scanner
-├── config.py # Paths + defaults
-├── facts.py # SQLite fact DB: schema, insert, queries
-├── ingester.py # Deferred ingestion worker + crash recovery
-├── scanner.py # Reconciliation scanner + watermark I/O
-├── schedule.py # Cron parsing + projection math
-├── cli.py # Standalone terminal interface
-├── logger.py # Shared logger
-├── checkpoint.py # Session state persistence
-├── dashboard/
-│ ├── manifest.json # Dashboard plugin manifest
-│ ├── plugin_api.py # FastAPI router
-│ ├── build.js # esbuild bundler script
-│ ├── src/ # Modular frontend source
-│ │ ├── index.js # Entry point
-│ │ ├── lib/ # SDK, formatters, icons, validators
-│ │ ├── hooks/ # useApi, useModal
-│ │ └── components/ # 13 React components
-│ └── dist/
-│ └── index.js # Bundled IIFE frontend
-└── tests/ # 83 pytest tests
-```
+Paths are resolved automatically:
+- `STATE_DB`: `~/.hermes/state.db` (Hermes core session store)
+- `FACT_DB`: `~/.hermes/plugins/cronalytics/facts.db` (plugin-owned SQLite)
+- `WATERMARK_FILE`: `~/.hermes/plugins/cronalytics/watermark.json`
+- `PENDING_FILE`: `~/.hermes/plugins/cronalytics/pending.jsonl`
---
@@ -321,24 +342,21 @@ cronalytics/
2. **Abandoned sessions are invisible.** Sessions where the gateway crashed or the job got stuck are never ingested (they never reach `ended_at`).
3. **No user-editable config file yet.** All tuning values are hardcoded in `config.py`.
4. **Actual cost is often null.** Most runs only populate `estimated_cost_usd`; `actual_cost_usd` depends on provider billing data.
-5. **Plugin directory is a static copy.** Changes in the build directory are not reflected in `~/.hermes/plugins/cronalytics/` unless manually copied or symlinked.
-6. **Dashboard server caches plugins per-process.** Changes to `manifest.json` or `plugin_api.py` require a full dashboard restart.
-7. **Mobile layout tested but not optimized.** The table may require horizontal scroll on narrow viewports.
-8. **Job detail modal capped at 200 runs.** High-frequency jobs show full count in the table but the drill-down is limited.
-
----
+5. **Dashboard server caches plugins per-process.** Changes to `manifest.json` or `plugin_api.py` require a full dashboard restart.
+6. **Mobile layout tested but not optimized.** The table may require horizontal scroll on narrow viewports.
+7. **Job detail modal capped at 200 runs.** High-frequency jobs show full count in the table but the drill-down is limited.
+See **[FAQ](docs/FAQ.md)** for more: the 250-run limit, agent vs no-agent, models breakdown, snapshotting your facts.db, and getting `cronalytics` on your PATH.
---
## Support
-This is an independent project built by a solo developer with help from an AI agent, and I'm grateful you are willing to try Cronayltics. I hope it helps optimize your cron activity. I use it daily and will fix bugs as I find them, but support and bug fixes will be on my **best effort** time schedule.
+Found a bug? Open a [GitHub Issue](https://github.com/8bit64k/cronalytics/issues) with reproduction steps. Have a feature idea? Open a [Discussion](https://github.com/8bit64k/cronalytics/discussions) or fork it.
-**Found a bug?** Open a [GitHub issue](https://github.com/8bit64k/cronalytics/issues) with reproduction steps.
-**Have a feature idea?** Open a [discussion](https://github.com/8bit64k/cronalytics/discussions) or fork it.
+See **[SUPPORT.md](SUPPORT.md)** for the full help guide, FAQ, and response expectations.
-Caveat: The cost estimates are approximate and as recorded by the Hermes Agent framework. The success/failure signal is wrapper-level only (see [Understanding Success](#understanding-success)). Verify anything mission-critical independently.
+**Caveat**: The cost estimates are approximate and as recorded by the Hermes Agent framework. The success/failure signal is wrapper-level only (see [Understanding Success](#understanding-success)). Verify anything mission-critical independently.
---
@@ -356,33 +374,15 @@ MIT — see [`LICENSE`](LICENSE) for full text.
---
-## Changelog
-
-### v1.0.1 (2026-05-13)
-
-- **Leader Board '% of total'** — spotlight cards show the leader's share of the window total (e.g. "42% of total cost")
-- **Cost card: suppressed Actual** — partial `actual_cost_usd` coverage creates misleading comparisons. The line now reads `Actual: —` until provider billing data coverage is reliable.
-- **Backend fix** — synthetic script-only rows now insert `NULL` for `actual_cost_usd` (was 0.0), eliminating phantom `$0.00` aggregates.
+## Acknowledgments
-### v1.0.0 (2026-05-12)
+Thanks to my wife **Gaby** for the Spanish translation review — and to everyone who starred, opened issues, and helped shape v1.1.
-- Dashboard: Summary Board, Leader Board, Per-Model Breakdown, Jobs Breakdown table
-- Sortable 8-column jobs table with expandable detail rows
-- Job Detail Modal with full run history, sticky headers, inherited sorting
-- Outcome toggle (All/Success/Failure) with conditional Cost card colors
-- Mode toggle (All/Agent/No agent) with script job visibility
-- Pace, Nominal, and Trend projections with educational modals
-- Reconciliation scanner with watermark-based backfill
-- Bootstrap scanner on plugin load (catches post-restart gaps)
-- 83 pytest tests covering facts, parser, scanner, schedule, ingester, plugin API
-- Lint/type check: `ruff` + `mypy` clean
-- Keyboard-accessible cards and table headers (a11y)
-- Large-font theme resilience
-- API validation layer (JSDoc typedefs + runtime guards)
+---
-### v0.1.0
+## Changelog
-- Initial release: real-time ingestion, fact DB, reconciliation scanner, dashboard API, React frontend with summary cards, jobs table, cost-by-model, sync button.
+See **[CHANGELOG.md](CHANGELOG.md)** for the full version history.
---
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..4d7f16f
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,25 @@
+# Support
+
+## Getting Help
+
+- **FAQ** — [docs/FAQ.md](docs/FAQ.md) answers common questions about costs, missing jobs, Pace, CLI setup, and more.
+- **Usage Guide** — [docs/USAGE.md](docs/USAGE.md) covers the dashboard layout, CLI commands, and agent skill workflows.
+- **Troubleshooting** — [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) has fixes for common errors and configuration issues.
+
+## Bugs and Feature Requests
+
+- **Found a bug?** Open a [Bug Report](https://github.com/8bit64k/cronalytics/issues/new?template=bug_report.md) with reproduction steps.
+- **Have an idea?** Open a [Feature Request](https://github.com/8bit64k/cronalytics/issues/new?template=feature_request.md) or start a [Discussion](https://github.com/8bit64k/cronalytics/discussions).
+
+Please include:
+- Cronalytics and Hermes version
+- Steps to reproduce (for bugs)
+- What you're trying to accomplish (for feature requests)
+
+## Response Time
+
+This is a community project maintained on a best-effort basis. We aim to respond to issues within a week and review PRs as they come in. Critical fixes for active releases get priority.
+
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for how to fork, build, test, and submit pull requests. Contributions are welcome — especially documentation fixes and translations.
\ No newline at end of file
diff --git a/__init__.py b/__init__.py
index 41569db..ad92833 100644
--- a/__init__.py
+++ b/__init__.py
@@ -9,7 +9,7 @@
import logging
import threading
-from . import config, facts, ingester
+from .cronalytics import config, facts, ingester
def register(ctx) -> None:
@@ -24,7 +24,7 @@ def register(ctx) -> None:
# gateway was down or the plugin was disabled.
def _bootstrap():
try:
- from . import scanner
+ from .cronalytics import scanner
result = scanner.run_sync(config.STATE_DB, config.FACT_DB, config.WATERMARK_FILE)
if result.get("inserted") or result.get("skipped"):
logging.getLogger("cronalytics").info(
diff --git a/cli.py b/cli.py
deleted file mode 100644
index 47e4ee8..0000000
--- a/cli.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""Cronalytics CLI — standalone terminal data dump.
-
-Usage:
- python -m cronalytics.cli summary [--days N]
- python -m cronalytics.cli jobs [--days N]
- python -m cronalytics.cli runs --job JOB_ID [--days N]
- python -m cronalytics.cli models [--days N]
- python -m cronalytics.cli trends [--days N]
- python -m cronalytics.cli health
-"""
-
-from __future__ import annotations
-
-import argparse
-import sys
-from datetime import datetime
-from pathlib import Path
-
-# ---------------------------------------------------------------------------
-# assuming we're run from inside the plugin repo; facts.py lives next door.
-# ---------------------------------------------------------------------------
-_HERE = Path(__file__).parent.resolve()
-sys.path.insert(0, str(_HERE))
-
-import config
-from facts import (
- get_conn,
- query_health,
- query_job_runs,
- query_jobs,
- query_models,
- query_summary,
- query_trends,
-)
-from schedule import get_job_projections
-
-# Hermes jobs.json lives under ~/.hermes/cron/jobs.json
-_JOBS_PATH = Path.home() / ".hermes" / "cron" / "jobs.json"
-
-# ---------------------------------------------------------------------------
-# Formatting helpers (match hermes insights compact style)
-# ---------------------------------------------------------------------------
-
-def _fmt_cost(n: float | None) -> str:
- if n is None:
- return "—"
- return f"${n:,.2f}"
-
-
-def _fmt_tokens(n: int) -> str:
- if n >= 1_000_000:
- return f"{n / 1_000_000:.1f}M"
- if n >= 1_000:
- return f"{n / 1_000:.0f}K"
- return str(n)
-
-
-def _fmt_dt(ts: float | None) -> str:
- if not ts:
- return "—"
- return datetime.fromtimestamp(ts).strftime("%b %d %H:%M")
-
-
-def _bar_chart(values: list[int], max_width: int = 20) -> list[str]:
- peak = max(values) if values else 1
- if peak == 0:
- return ["" for _ in values]
- return ["█" * max(1, int(v / peak * max_width)) if v > 0 else "" for v in values]
-
-
-# ---------------------------------------------------------------------------
-# Commands
-# ---------------------------------------------------------------------------
-
-def cmd_summary(args: argparse.Namespace) -> int:
- days: int = args.days
- data = query_summary(config.FACT_DB, days=days)
-
- period = f"Last {days} days" if days > 0 else "All time"
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- " ║ 📊 Cronalytics Summary ║",
- f" ║ {period:^52} ║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- ]
-
- lines.append(f" Runs: {data['total_runs']:,}")
- lines.append(f" Estimated cost: {_fmt_cost(data['total_estimated_cost'])}")
- lines.append(f" Actual cost: {_fmt_cost(data['total_actual_cost'])}")
- lines.append(f" Tokens: {_fmt_tokens(data['total_tokens'])} "
- f"(In: {_fmt_tokens(data['total_input_tokens'])} "
- f"Out: {_fmt_tokens(data['total_output_tokens'])} "
- f"Cached: {_fmt_tokens(data['total_cache_read_tokens'])})")
- lines.append("")
-
- prev = data.get("previous_period", {})
- if prev:
- lines.append(f" Previous period: {prev.get('runs', 0):,} runs, "
- f"{_fmt_cost(prev.get('cost'))}")
- lines.append(f" Trend: {data['trend']}")
- lines.append("")
-
- by_model = data.get("cost_by_model", [])
- if by_model:
- lines.append(" 🤖 Cost by Model")
- lines.append(" " + "─" * 56)
- lines.append(f" {'Model':30} {'Runs':>8} {'Cost':>12}")
- for m in by_model:
- lines.append(f" {(m['model'] or 'unknown'):30} {m['runs']:>8,} {_fmt_cost(m['total_cost']):>12}")
- lines.append("")
-
- print("\n".join(lines))
- return 0
-
-
-def cmd_jobs(args: argparse.Namespace) -> int:
- days: int = args.days
- jobs = query_jobs(config.FACT_DB, days=days)
- if not jobs:
- print(f" No cron jobs found in the last {days} days.")
- return 0
-
- period = f"Last {days} days" if days > 0 else "All time"
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- " ║ 🤖 Cronalytics Jobs ║",
- f" ║ {period:^52} ║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- " Jobs overview",
- " " + "─" * 56,
- f" {'Job ID':20} {'Runs':>5} {'Cost':>10} {'Tokens':>10} {'Pace':>6}",
- " " + "─" * 56,
- ]
-
- for j in jobs:
- job_id = j["job_id"][:18]
- cost = _fmt_cost(j["total_cost"])
- tokens = _fmt_tokens(j["total_tokens"])
-
- # Pace requires projection data
- proj = get_job_projections(
- j["job_id"],
- avg_cost=j.get("avg_cost"),
- total_cost=j["total_cost"],
- runs=j["runs"],
- first_run=j.get("first_run"),
- last_run=j.get("last_run"),
- days_filter=days,
- jobs_json_path=_JOBS_PATH,
- )
- pace = proj.get("pace")
- pace_str = f"{pace:.2f}" if pace is not None else "—"
-
- lines.append(f" {job_id:20} {j['runs']:>5} {cost:>10} {tokens:>10} {pace_str:>6}")
-
- lines.append("")
- print("\n".join(lines))
- return 0
-
-
-def cmd_runs(args: argparse.Namespace) -> int:
- days: int = args.days
- job_id: str = args.job
- runs = query_job_runs(config.FACT_DB, job_id=job_id, limit=50, days=days)
- if not runs:
- print(f" No runs found for job '{job_id}' in the last {days} days.")
- return 0
-
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- f" ║ 📝 Runs: {job_id[:36]:37}║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- f" {'Time':16} {'Dur':>6} {'Cost':>9} {'Tokens':>8} {'Model':25}",
- " " + "─" * 66,
- ]
-
- for r in runs:
- t = _fmt_dt(r.get("run_time"))
- dur = r.get("duration_seconds")
- dur_str = f"{dur:.0f}s" if dur is not None else "—"
- cost = _fmt_cost(r.get("estimated_cost_usd"))
- tok = _fmt_tokens(
- r.get("input_tokens", 0) + r.get("output_tokens", 0) +
- r.get("cache_read_tokens", 0) + r.get("cache_write_tokens", 0)
- )
- model = (r.get("model") or "unknown")[:24]
- lines.append(f" {t:16} {dur_str:>6} {cost:>9} {tok:>8} {model:25}")
-
- lines.append("")
- print("\n".join(lines))
- return 0
-
-
-def cmd_models(args: argparse.Namespace) -> int:
- days: int = args.days
- models = query_models(config.FACT_DB, days=days)
- if not models:
- print(f" No model data found in the last {days} days.")
- return 0
-
- period = f"Last {days} days" if days > 0 else "All time"
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- " ║ 🤖 Cronalytics Models ║",
- f" ║ {period:^52} ║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- f" {'Model':30} {'Runs':>8} {'Cost':>10} {'Tokens':>10}",
- " " + "─" * 60,
- ]
-
- for m in models:
- model = (m["model"] or "unknown")[:28]
- cost = _fmt_cost(m["total_cost"])
- tokens = _fmt_tokens(m.get("total_input_tokens", 0) + m.get("total_output_tokens", 0))
- lines.append(f" {model:30} {m['runs']:>8,} {cost:>10} {tokens:>10}")
-
- lines.append("")
- print("\n".join(lines))
- return 0
-
-
-def cmd_trends(args: argparse.Namespace) -> int:
- days: int = args.days
- trends = query_trends(config.FACT_DB, days=days)
- if not trends:
- print(f" No trend data found in the last {days} days.")
- return 0
-
- period = f"Last {days} days" if days > 0 else "All time"
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- " ║ 📈 Cronalytics Trends ║",
- f" ║ {period:^52} ║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- ]
-
- vals = [t["runs"] for t in trends]
- bars = _bar_chart(vals, max_width=25)
- for i, t in enumerate(trends):
- bar = bars[i]
- cost = _fmt_cost(t.get("cost"))
- lines.append(f" {t['day']} {bar:25} {t['runs']:>3} runs {cost}")
-
- lines.append("")
- print("\n".join(lines))
- return 0
-
-
-def cmd_health(_args: argparse.Namespace) -> int:
- h = query_health(config.FACT_DB)
- lines: list[str] = [
- "",
- " ╔══════════════════════════════════════════════════════════╗",
- " ║ 💓 Cronalytics Health ║",
- " ╚══════════════════════════════════════════════════════════╝",
- "",
- f" Total runs: {h['total_runs']:,}",
- f" Unique jobs: {h['unique_jobs']}",
- f" Last ingested: {_fmt_dt(h.get('last_ingested_at'))}",
- f" Last run time: {_fmt_dt(h.get('last_run_time'))}",
- "",
- ]
- print("\n".join(lines))
- return 0
-
-
-# ---------------------------------------------------------------------------
-# Argument parsing
-# ---------------------------------------------------------------------------
-
-def main(argv: list[str] | None = None) -> int:
- parser = argparse.ArgumentParser(
- prog="cronalytics",
- description="Cronalytics CLI — dump cron run insights to the terminal",
- )
- parser.add_argument(
- "--db",
- type=Path,
- default=None,
- help="Path to fact DB (default: facts.db)",
- )
- subparsers = parser.add_subparsers(dest="command", required=True)
-
- for name, help_text in (
- ("summary", "Aggregate headline summary"),
- ("jobs", "Per-job breakdown with pace"),
- ("models", "Per-model cost breakdown"),
- ("trends", "Daily run-count / cost sparkline"),
- ("health", "Fact DB health check"),
- ):
- p = subparsers.add_parser(name, help=help_text)
- p.add_argument(
- "--days",
- type=int,
- default=30,
- help="Number of days to look back (default: 30, 0 = all time)",
- )
- p.add_argument(
- "--db",
- type=Path,
- default=None,
- help="Path to fact DB (default: facts.db)",
- )
-
- runs_parser = subparsers.add_parser("runs", help="Individual runs for a job")
- runs_parser.add_argument(
- "--days",
- type=int,
- default=30,
- help="Number of days to look back (default: 30, 0 = all time)",
- )
- runs_parser.add_argument("--job", required=True, help="Job ID filter")
- runs_parser.add_argument(
- "--db",
- type=Path,
- default=None,
- help="Path to fact DB (default: facts.db)",
- )
-
- args = parser.parse_args(argv)
-
- db_path = args.db or config.FACT_DB
-
- # Ensure DB exists / schema up-to-date
- get_conn(db_path)
-
- # Redirect all command handlers to use the specified DB
- config.FACT_DB = db_path
-
- handler = {
- "summary": cmd_summary,
- "jobs": cmd_jobs,
- "runs": cmd_runs,
- "models": cmd_models,
- "trends": cmd_trends,
- "health": cmd_health,
- }[args.command]
-
- try:
- return handler(args)
- except Exception as exc:
- print(f"Error: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/cronalytics/__init__.py b/cronalytics/__init__.py
new file mode 100644
index 0000000..b3e7697
--- /dev/null
+++ b/cronalytics/__init__.py
@@ -0,0 +1 @@
+"""Cronalytics — cron observability package."""
diff --git a/cronalytics/cli.py b/cronalytics/cli.py
new file mode 100644
index 0000000..6d3e939
--- /dev/null
+++ b/cronalytics/cli.py
@@ -0,0 +1,1091 @@
+"""Cronalytics CLI — terminal data tool.
+
+Usage (dashboard plugin install — primary):
+ cronalytics [--days N] [--outcome all|success|failure] [--mode all|agent|no_agent]
+ cronalytics summary [--days N] [--outcome ...] [--mode ...] [--json]
+ cronalytics jobs [--days N] [--outcome ...] [--mode ...] [--json]
+ cronalytics runs --job JOB_ID [--days N] [--outcome ...] [--mode ...] [--json]
+ cronalytics models [--days N] [--outcome ...] [--mode ...] [--json]
+ cronalytics trends [--days N] [--outcome ...] [--mode ...] [--json]
+ cronalytics all [--days N] [--outcome ...] [--mode ...]
+ cronalytics health [--json]
+
+Usage (pip install -e / direct module):
+ pip install -e ~/.hermes/plugins/cronalytics --break-system-packages
+ alias cronalytics='python ~/.hermes/plugins/cronalytics/cronalytics/cli.py'
+ cronalytics summary --days 14
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sqlite3
+import sys
+import unicodedata
+from collections.abc import Callable
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, TypedDict
+
+# ---------------------------------------------------------------------------
+# Path bootstrap — add plugin root so `cronalytics` package is discoverable
+# ---------------------------------------------------------------------------
+_PLUGIN_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(_PLUGIN_ROOT))
+
+from cronalytics import config # noqa: I001
+from cronalytics.facts import (
+ get_conn,
+ query_health,
+ query_job_runs,
+ query_jobs,
+ query_models,
+ query_summary,
+ query_trends,
+)
+from cronalytics.schedule import get_job_projections
+
+
+# =============================================================================
+# CONSTANTS
+# =============================================================================
+
+# Box-drawing characters
+_CHAR_TOP_LEFT = "╔"
+_CHAR_TOP_RIGHT = "╗"
+_CHAR_BOTTOM_LEFT = "╚"
+_CHAR_BOTTOM_RIGHT = "╝"
+_CHAR_HORIZ = "═"
+_CHAR_VERT = "║"
+_CHAR_SEP = "─"
+
+# Banner inner widths (content area between ║ borders)
+_W_STD = 72 # Standard: health, summary, models, trends, runs, full report
+_W_JOBS = 82 # Wider for jobs table
+
+# Column layouts: list of (header, width) tuples.
+# Total separator width == sum(widths) + (count - 1) spaces.
+JOBS_LAYOUT: list[tuple[str, int]] = [
+ ("Job ID", 12),
+ ("Job", 14),
+ ("Runs", 5),
+ ("Est Cost", 10),
+ ("Dur", 6),
+ ("Tokens", 10),
+ ("Pace", 5),
+]
+SUMMARY_MODEL_LAYOUT: list[tuple[str, int]] = [
+ ("Model", 30),
+ ("Runs", 8),
+ ("Est Cost", 12),
+]
+MODELS_LAYOUT: list[tuple[str, int]] = [
+ ("Model", 30),
+ ("Runs", 8),
+ ("Est Cost", 10),
+ ("Tokens", 10),
+]
+RUNS_LAYOUT: list[tuple[str, int]] = [
+ ("Time", 16),
+ ("Dur", 5),
+ ("Est Cost", 8),
+ ("Tks", 7),
+ ("M", 22),
+ ("✓", 2),
+]
+LEADER_BOARD_LAYOUT: list[tuple[str, int]] = [
+ ("", 14), # Category (e.g., "Top Runs")
+ ("", 14), # Job name
+ ("", 12), # Job ID
+ ("", 10), # Value
+ ("", 8), # Share
+]
+
+# Pre-computed separator line widths
+JOBS_SEP_WIDTH = sum(w for _, w in JOBS_LAYOUT) + len(JOBS_LAYOUT) - 1
+SUMMARY_MODEL_SEP_WIDTH = sum(w for _, w in SUMMARY_MODEL_LAYOUT) + len(SUMMARY_MODEL_LAYOUT) - 1
+MODELS_SEP_WIDTH = sum(w for _, w in MODELS_LAYOUT) + len(MODELS_LAYOUT) - 1
+RUNS_SEP_WIDTH = sum(w for _, w in RUNS_LAYOUT) + len(RUNS_LAYOUT) - 1
+LEADER_BOARD_SEP_WIDTH = sum(w for _, w in LEADER_BOARD_LAYOUT) + len(LEADER_BOARD_LAYOUT) - 1
+
+# Job-name truncation limits
+_JOBS_NAME_MAX_AGENT = 14
+_JOBS_NAME_MAX_NO_AGENT = 10 # room for " [N]" suffix
+
+# Hermes jobs.json path
+_JOBS_PATH: Path = config.HERMES_HOME / "cron" / "jobs.json"
+
+
+# =============================================================================
+# TYPES
+# =============================================================================
+
+class SummaryData(TypedDict, total=False):
+ """Shape returned by facts.query_summary."""
+
+ days: int
+ total_runs: int
+ total_estimated_cost: float | None
+ total_actual_cost: float | None
+ total_tokens: int
+ total_input_tokens: int
+ total_output_tokens: int
+ total_cache_read_tokens: int
+ total_cache_write_tokens: int
+ total_duration_seconds: float
+ avg_duration_seconds: float
+ success_runs: int
+ failure_runs: int
+ success_cost: float
+ failure_cost: float
+ cost_by_model: list[CostByModel]
+ previous_period: dict[str, Any] | None
+ trend: str
+
+
+class CostByModel(TypedDict, total=False):
+ """Per-model summary within the overall summary."""
+
+ model: str | None
+ runs: int
+ total_cost: float | None
+
+
+class JobData(TypedDict, total=False):
+ """Shape returned by facts.query_jobs."""
+
+ job_id: str
+ job_name: str | None
+ runs: int
+ total_cost: float | None
+ total_tokens: int
+ avg_duration: float | None
+ job_mode: str | None
+ avg_cost: float | None
+ first_run: float | None
+ last_run: float | None
+
+
+class ModelData(TypedDict, total=False):
+ """Shape returned by facts.query_models."""
+
+ model: str | None
+ runs: int
+ total_cost: float | None
+ total_input_tokens: int
+ total_output_tokens: int
+
+
+class TrendData(TypedDict, total=False):
+ """Shape returned by facts.query_trends."""
+
+ day: str
+ runs: int
+ cost: float | None
+
+
+class RunData(TypedDict, total=False):
+ """Shape returned by facts.query_job_runs."""
+
+ session_id: str
+ job_id: str
+ job_name: str | None
+ run_time: float | None
+ estimated_cost_usd: float | None
+ input_tokens: int
+ output_tokens: int
+ cache_read_tokens: int
+ cache_write_tokens: int
+ duration_seconds: float | None
+ model: str | None
+ job_mode: str | None
+ success: bool
+
+
+class HealthData(TypedDict, total=False):
+ """Shape returned by facts.query_health."""
+
+ total_runs: int
+ unique_jobs: int
+ last_ingested_at: float | None
+ last_run_time: float | None
+
+
+class JsonEnvelope(TypedDict, total=False):
+ """Lightweight wrapper around any command's data payload."""
+
+ period: str
+ data: Any
+ start_date: str
+ end_date: str
+ outcome: str
+ mode: str
+
+
+# =============================================================================
+# PURE DATA FUNCTIONS (no side effects, fully testable)
+# =============================================================================
+
+def _resolve_db(cli_db: Path | None) -> Path:
+ """Resolve the fact DB path.
+
+ Priority:
+ 1. Explicit --db flag
+ 2. config.FACT_DB (resolved via HERMES_HOME env var)
+ """
+ if cli_db is not None:
+ return cli_db
+ return config.FACT_DB
+
+
+def _load_job_names(jobs_path: Path = _JOBS_PATH) -> dict[str, str]:
+ """Load job_id → name mapping from Hermes jobs.json.
+
+ Supports three formats:
+ {"jobs": [{"id": ..., "name": ...}, ...]} # Hermes native
+ {"job_id": {"name": ...}, ...} # flat dict
+ [{"id": ..., "name": ...}, ...] # plain list
+ """
+ if not jobs_path.exists():
+ return {}
+ try:
+ data = json.loads(jobs_path.read_text())
+ if isinstance(data, dict) and "jobs" in data and isinstance(data["jobs"], list):
+ return {j.get("id", ""): j.get("name", "") for j in data["jobs"]}
+ if isinstance(data, dict):
+ return {jid: info.get("name", "") for jid, info in data.items() if isinstance(info, dict)}
+ if isinstance(data, list):
+ return {j.get("id", ""): j.get("name", "") for j in data}
+ except (OSError, json.JSONDecodeError):
+ pass
+ return {}
+
+
+def _db_date_range(db_path: Path) -> tuple[str, str]:
+ """Query the DB for the actual min/max run_time dates.
+
+ Returns (start_date, end_date) as ISO strings, or ("", "") if the table
+ is empty or the DB is not yet initialized.
+ """
+ try:
+ conn = get_conn(db_path)
+ cur = conn.cursor()
+ cur.execute("SELECT MIN(run_time), MAX(run_time) FROM cron_runs")
+ row = cur.fetchone()
+ if row and row[0] and row[1]:
+ start = datetime.fromtimestamp(row[0])
+ end = datetime.fromtimestamp(row[1])
+ return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
+ except (sqlite3.Error, OSError):
+ pass
+ return "", ""
+
+
+def _json_dates(days: int, db_path: Path) -> tuple[str, str]:
+ """Return ISO start_date and end_date for a day filter.
+
+ For --days 0 (all time) the range is computed from the DB itself.
+ """
+ if days == 0:
+ return _db_date_range(db_path)
+ today = datetime.now()
+ start = today - timedelta(days=days - 1)
+ end = today
+ return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")
+
+
+def _human_date_range(days: int, db_path: Path) -> str:
+ """Return a human-readable date range for the given day filter.
+
+ For --days 0 (all time) the range is computed from the DB itself.
+ """
+ if days == 0:
+ start_str, end_str = _db_date_range(db_path)
+ if start_str and end_str:
+ start = datetime.strptime(start_str, "%Y-%m-%d")
+ end = datetime.strptime(end_str, "%Y-%m-%d")
+ return f"{start.strftime('%b %d')} — {end.strftime('%b %d, %Y')}"
+ return ""
+ today = datetime.now()
+ start = today - timedelta(days=days - 1)
+ end = today
+ return f"{start.strftime('%b %d')} — {end.strftime('%b %d, %Y')}"
+
+
+def _json_envelope(
+ data: Any,
+ args: argparse.Namespace,
+ db_path: Path,
+ include_filters: bool = True,
+) -> JsonEnvelope:
+ """Wrap raw data in a lightweight envelope with period + filter context."""
+ days: int = args.days
+ period = f"Last {days} days" if days > 0 else "All time"
+ start_date, end_date = _json_dates(days, db_path)
+ envelope: JsonEnvelope = {"period": period, "data": data}
+ if start_date:
+ envelope["start_date"] = start_date
+ envelope["end_date"] = end_date
+ if include_filters:
+ envelope["outcome"] = args.outcome
+ envelope["mode"] = args.mode
+ return envelope
+
+
+# =============================================================================
+# PURE FORMATTING FUNCTIONS (no side effects, fully testable)
+# =============================================================================
+
+def _fmt_cost(n: float | None) -> str:
+ """Format a cost value as a US-dollar string."""
+ if n is None:
+ return "—"
+ return f"${n:,.2f}"
+
+
+def _fmt_tokens(n: int) -> str:
+ """Format a token count with K/M suffixes."""
+ if n >= 1_000_000:
+ return f"{n / 1_000_000:.1f}M"
+ if n >= 1_000:
+ return f"{n / 1_000:.0f}K"
+ return str(n)
+
+
+def _fmt_dt(ts: float | None) -> str:
+ """Format a Unix timestamp as 'Mon DD HH:MM'."""
+ if not ts:
+ return "—"
+ return datetime.fromtimestamp(ts).strftime("%b %d %H:%M")
+
+
+def _bar_chart(values: list[int], *, max_width: int = 20) -> list[str]:
+ """Build a list of Unicode block-bar strings proportional to values."""
+ peak = max(values) if values else 1
+ if peak == 0:
+ return ["" for _ in values]
+ return [
+ "█" * max(1, int(v / peak * max_width)) if v > 0 else ""
+ for v in values
+ ]
+
+
+def _visual_len(text: str) -> int:
+ """Return visual display width, counting East-Asian wide chars as 2."""
+ return sum(
+ 2 if unicodedata.east_asian_width(ch) in ("F", "W") else 1
+ for ch in text
+ )
+
+
+def _banner_line(text: str, inner_width: int) -> str:
+ """Center *text* inside a box-drawing line with correct visual alignment."""
+ vis = _visual_len(text)
+ pad = max(0, inner_width - vis)
+ left = pad // 2
+ right = pad - left
+ return f" {_CHAR_VERT}{' ' * left}{text}{' ' * right}{_CHAR_VERT}"
+
+
+def _banner(
+ title: str,
+ subtitle: str | None = None,
+ *,
+ inner_width: int = _W_STD,
+) -> list[str]:
+ """Build a boxed banner: top border, title line, optional subtitle, bottom border."""
+ border = _CHAR_HORIZ * inner_width
+ lines = [f" {_CHAR_TOP_LEFT}{border}{_CHAR_TOP_RIGHT}"]
+ lines.append(_banner_line(title, inner_width))
+ if subtitle:
+ lines.append(_banner_line(subtitle, inner_width))
+ lines.append(f" {_CHAR_BOTTOM_LEFT}{border}{_CHAR_BOTTOM_RIGHT}")
+ return lines
+
+
+def _build_table_header(layout: list[tuple[str, int]]) -> str:
+ """Return a formatted header string from a column layout."""
+ _right_aligned = {"Runs", "Est Cost", "Dur", "Tokens", "Pace", "Tks", "M", "✓"}
+ parts: list[str] = []
+ for header, width in layout:
+ align = ">" if header in _right_aligned else "<"
+ parts.append(f"{header:{align}{width}}")
+ return " " + " ".join(parts)
+
+
+def _build_separator(width: int) -> str:
+ """Return a separator line of the given total width."""
+ return " " + _CHAR_SEP * width
+
+
+# =============================================================================
+# DATA FETCHING (separated from presentation)
+# =============================================================================
+# The facts module is untyped, so we use Any for return values and cast
+# internally in the render functions where structure is guaranteed.
+
+
+def _fetch_summary(db_path: Path, args: argparse.Namespace) -> Any:
+ """Fetch aggregate headline data."""
+ return query_summary(db_path, days=args.days, outcome=args.outcome, mode=args.mode)
+
+
+def _fetch_jobs(db_path: Path, args: argparse.Namespace) -> Any:
+ """Fetch per-job breakdown."""
+ jobs = query_jobs(db_path, days=args.days, outcome=args.outcome, mode=args.mode)
+ job_names = _load_job_names()
+ for j in jobs:
+ j["job_name"] = job_names.get(j["job_id"], "") or None
+ return jobs
+
+
+def _fetch_models(db_path: Path, args: argparse.Namespace) -> Any:
+ """Fetch per-model cost breakdown."""
+ return query_models(db_path, days=args.days, outcome=args.outcome, mode=args.mode)
+
+
+def _fetch_trends(db_path: Path, args: argparse.Namespace) -> Any:
+ """Fetch daily run-count / cost sparkline data."""
+ return query_trends(db_path, days=args.days, outcome=args.outcome, mode=args.mode)
+
+
+def _fetch_runs(db_path: Path, args: argparse.Namespace) -> Any:
+ """Fetch individual runs for a specific job."""
+ runs = query_job_runs(
+ db_path,
+ job_id=args.job,
+ limit=args.limit,
+ days=args.days,
+ outcome=args.outcome,
+ mode=args.mode,
+ )
+ job_names = _load_job_names()
+ for r in runs:
+ r["job_name"] = job_names.get(r["job_id"], "") or None
+ return runs
+
+
+def _fetch_health(db_path: Path) -> Any:
+ """Fetch fact DB health metadata."""
+ return query_health(db_path)
+
+
+def _job_label(job_id: str, job_names: dict[str, str], mode: str | None) -> str:
+ """Build a display label for a job with truncation and [N] badge."""
+ label = job_names.get(job_id, "") or job_id
+ max_base = _JOBS_NAME_MAX_NO_AGENT if mode == "no_agent" else _JOBS_NAME_MAX_AGENT
+ if len(label) > max_base:
+ label = label[: max_base - 1] + "…"
+ if mode == "no_agent":
+ label += " [N]"
+ return label
+
+
+def _compute_leader_board(
+ jobs: list[Any],
+ summary_data: dict[str, Any],
+ job_names: dict[str, str],
+ days_filter: int,
+) -> list[dict[str, Any]]:
+ """Compute the top job for each leader board category."""
+ if not jobs:
+ return []
+
+ total_runs = summary_data.get("total_runs", 0)
+ total_cost = summary_data.get("tot_estimated_cost") or 0
+ total_tokens = summary_data.get("total_tokens", 0)
+
+ def _get_pace(job: Any) -> float | None:
+ proj = get_job_projections(
+ job["job_id"],
+ avg_estimated_cost=job.get("avg_estimated_cost"),
+ tot_estimated_cost=job["tot_estimated_cost"],
+ runs=job["runs"],
+ first_run=job.get("first_run"),
+ last_run=job.get("last_run"),
+ days_filter=days_filter,
+ jobs_json_path=_JOBS_PATH,
+ )
+ return proj.get("pace")
+
+ top_runs_job = max(jobs, key=lambda j: j["runs"])
+ top_cost_job = max(jobs, key=lambda j: j.get("tot_estimated_cost") or 0)
+ top_tokens_job = max(jobs, key=lambda j: j.get("total_tokens", 0))
+
+ jobs_with_pace = [(j, _get_pace(j)) for j in jobs if _get_pace(j) is not None]
+ if jobs_with_pace:
+ top_pace_job, top_pace_val = max(jobs_with_pace, key=lambda x: x[1] or 0)
+ else:
+ top_pace_job = None
+ top_pace_val = None
+
+ leaders: list[dict[str, Any]] = []
+
+ if total_runs > 0:
+ leaders.append({
+ "category": "Top Runs",
+ "job_id": top_runs_job["job_id"],
+ "job_name": _job_label(top_runs_job["job_id"], job_names, top_runs_job.get("job_mode")),
+ "value": f"{top_runs_job['runs']:,}",
+ "share": f"{top_runs_job['runs'] / total_runs * 100:.1f}%",
+ })
+
+ if total_cost > 0:
+ cost = top_cost_job.get("tot_estimated_cost") or 0
+ leaders.append({
+ "category": "Top Est Cost",
+ "job_id": top_cost_job["job_id"],
+ "job_name": _job_label(top_cost_job["job_id"], job_names, top_cost_job.get("job_mode")),
+ "value": _fmt_cost(cost),
+ "share": f"{cost / total_cost * 100:.1f}%",
+ })
+
+ if total_tokens > 0:
+ tokens = top_tokens_job.get("total_tokens", 0)
+ leaders.append({
+ "category": "Top Tokens",
+ "job_id": top_tokens_job["job_id"],
+ "job_name": _job_label(top_tokens_job["job_id"], job_names, top_tokens_job.get("job_mode")),
+ "value": _fmt_tokens(tokens),
+ "share": f"{tokens / total_tokens * 100:.1f}%",
+ })
+
+ if top_pace_job and top_pace_val is not None:
+ leaders.append({
+ "category": "Top Pace",
+ "job_id": top_pace_job["job_id"],
+ "job_name": _job_label(top_pace_job["job_id"], job_names, top_pace_job.get("job_mode")),
+ "value": f"{top_pace_val:.2f}×",
+ "share": "—",
+ })
+
+ return leaders
+
+
+# =============================================================================
+# RENDERING (separated from data fetching; accepts Any because facts.py is untyped)
+# =============================================================================
+
+def _render_summary(data: Any, args: argparse.Namespace, db_path: Path) -> list[str]:
+ """Build the pretty-printed lines for the summary command."""
+ days = args.days
+ period = f"Last {days} days" if days > 0 else "All time"
+ dr = _human_date_range(days, db_path)
+ subtitle = f"{period} · {dr}" if dr else period
+
+ lines: list[str] = [""] + _banner("📊 Cronalytics Summary", subtitle, inner_width=_W_STD) + [""]
+
+ lines.append(" 📋 Summary Board")
+ lines.append(_build_separator(_W_STD))
+ lines.append(f" Runs: {data['total_runs']:,}")
+ lines.append(f" Estimated cost: {_fmt_cost(data['tot_estimated_cost'])}")
+ lines.append(f" Actual cost: {_fmt_cost(data['tot_actual_cost'])}")
+ lines.append(
+ f" Tokens: {_fmt_tokens(data['total_tokens'])} "
+ f"(In: {_fmt_tokens(data['total_input_tokens'])} "
+ f"Out: {_fmt_tokens(data['total_output_tokens'])} "
+ f"Cached: {_fmt_tokens(data['total_cache_read_tokens'])})"
+ )
+ lines.append("")
+
+ prev = data.get("previous_period", {})
+ if prev:
+ lines.append(
+ f" Previous period: {prev.get('runs', 0):,} runs, "
+ f"{_fmt_cost(prev.get('estimated_cost'))}" # type: ignore[arg-type]
+ )
+ lines.append(f" Trend: {data['trend']}")
+ lines.append("")
+
+ leader_board = data.get("leader_board")
+ if leader_board:
+ lines.append(" 🏆 Leader Board")
+ lines.append(_build_separator(LEADER_BOARD_SEP_WIDTH))
+ for leader in leader_board:
+ lines.append(
+ f" {leader['category']:<14} {leader['job_name']:<14} "
+ f"{leader['job_id']:<12} {leader['value']:>10} {leader['share']:>8}"
+ )
+ lines.append("")
+
+ by_model = data.get("cost_by_model", [])
+ if by_model:
+ lines.append(" 🤖 Cost by Model")
+ lines.append(_build_separator(SUMMARY_MODEL_SEP_WIDTH))
+ lines.append(_build_table_header(SUMMARY_MODEL_LAYOUT))
+ for m in by_model:
+ model_name = (m["model"] or "unknown")[:28]
+ lines.append(
+ f" {model_name:<30} {m['runs']:>8,} {_fmt_cost(m['tot_estimated_cost']):>12}"
+ )
+ lines.append("")
+
+ return lines
+
+
+def _render_jobs(
+ jobs: Any,
+ args: argparse.Namespace,
+ db_path: Path,
+ job_names: dict[str, str],
+) -> list[str]:
+ """Build the pretty-printed lines for the jobs command."""
+ days = args.days
+ period = f"Last {days} days" if days > 0 else "All time"
+ dr = _human_date_range(days, db_path)
+ subtitle = f"{period} · {dr}" if dr else period
+
+ lines: list[str] = [""] + _banner("🤖 Cronalytics Jobs", subtitle, inner_width=_W_JOBS) + [""]
+ lines.append(" Jobs overview")
+ lines.append(_build_separator(JOBS_SEP_WIDTH))
+ lines.append(_build_table_header(JOBS_LAYOUT))
+ lines.append(_build_separator(JOBS_SEP_WIDTH))
+
+ for j in jobs:
+ job_id = j["job_id"]
+ job_label = _job_label(job_id, job_names, j.get("job_mode"))
+ cost = _fmt_cost(j["tot_estimated_cost"])
+ tokens = _fmt_tokens(j["total_tokens"])
+ dur = j.get("avg_duration")
+ dur_str = f"{dur:.0f}s" if dur is not None else "—"
+
+ proj = get_job_projections(
+ job_id,
+ avg_estimated_cost=j.get("avg_estimated_cost"),
+ tot_estimated_cost=j["tot_estimated_cost"],
+ runs=j["runs"],
+ first_run=j.get("first_run"),
+ last_run=j.get("last_run"),
+ days_filter=days,
+ jobs_json_path=_JOBS_PATH,
+ )
+ pace = proj.get("pace")
+ pace_str = f"{pace:.2f}" if pace is not None else "—"
+
+ lines.append(
+ f" {job_id:<12} {job_label:<14} {j['runs']:>5} {cost:>10} {dur_str:>6} "
+ f"{tokens:>10} {pace_str:>5}"
+ )
+
+ lines.append("")
+ return lines
+
+
+def _render_runs(
+ runs: Any,
+ args: argparse.Namespace,
+ db_path: Path,
+) -> list[str]:
+ """Build the pretty-printed lines for the runs command."""
+ days = args.days
+ job_id = args.job
+ dr = _human_date_range(days, db_path)
+ subtitle = f"Last {days} days · {dr}" if dr else "All time"
+
+ lines: list[str] = [""] + _banner(f"📝 Runs: {job_id}", subtitle, inner_width=_W_STD) + [""]
+ lines.append(_build_table_header(RUNS_LAYOUT))
+ lines.append(_build_separator(RUNS_SEP_WIDTH))
+
+ for r in runs:
+ t = _fmt_dt(r.get("run_time"))
+ dur = r.get("duration_seconds")
+ dur_str = f"{dur:.0f}s" if dur is not None else "—"
+ cost = _fmt_cost(r.get("estimated_cost"))
+ tok = _fmt_tokens(
+ r.get("input_tokens", 0)
+ + r.get("output_tokens", 0)
+ + r.get("cache_read_tokens", 0)
+ + r.get("cache_write_tokens", 0)
+ )
+ model = (r.get("model") or "unknown")[:21]
+ if r.get("job_mode") == "no_agent":
+ model += " [N]"
+ ok = "✓" if r.get("success") else "✗"
+ lines.append(
+ f" {t:<16} {dur_str:>5} {cost:>8} {tok:>7} {model:<22} {ok:>2}"
+ )
+
+ lines.append("")
+ return lines
+
+
+def _render_models(
+ models: Any,
+ args: argparse.Namespace,
+ db_path: Path,
+) -> list[str]:
+ """Build the pretty-printed lines for the models command."""
+ days = args.days
+ period = f"Last {days} days" if days > 0 else "All time"
+ dr = _human_date_range(days, db_path)
+ subtitle = f"{period} · {dr}" if dr else period
+
+ lines: list[str] = [""] + _banner("🤖 Cronalytics Models", subtitle, inner_width=_W_STD) + [""]
+ lines.append(_build_table_header(MODELS_LAYOUT))
+ lines.append(_build_separator(MODELS_SEP_WIDTH))
+
+ for m in models:
+ model = (m["model"] or "unknown")[:28]
+ cost = _fmt_cost(m["tot_estimated_cost"])
+ tokens = _fmt_tokens(m.get("total_input_tokens", 0) + m.get("total_output_tokens", 0))
+ lines.append(
+ f" {model:<30} {m['runs']:>8,} {cost:>10} {tokens:>10}"
+ )
+
+ lines.append("")
+ return lines
+
+
+def _render_trends(
+ trends: Any,
+ args: argparse.Namespace,
+ db_path: Path,
+) -> list[str]:
+ """Build the pretty-printed lines for the trends command."""
+ days = args.days
+ period = f"Last {days} days" if days > 0 else "All time"
+ dr = _human_date_range(days, db_path)
+ subtitle = f"{period} · {dr}" if dr else period
+
+ lines: list[str] = [""] + _banner("📈 Cronalytics Trends", subtitle, inner_width=_W_STD) + [""]
+
+ vals = [t["runs"] for t in trends]
+ bars = _bar_chart(vals, max_width=25)
+ for i, t in enumerate(trends):
+ bar = bars[i]
+ cost = _fmt_cost(t.get("cost"))
+ lines.append(f" {t['day']} {bar:<25} {t['runs']:>3} runs {cost}")
+
+ lines.append("")
+ return lines
+
+
+def _render_health(data: Any) -> list[str]:
+ """Build the pretty-printed lines for the health command."""
+ lines: list[str] = [
+ "",
+ ] + _banner("💓 Cronalytics Health", inner_width=_W_STD) + [
+ "",
+ f" Total runs: {data['total_runs']:,}",
+ f" Unique jobs: {data['unique_jobs']}",
+ f" Last ingested: {_fmt_dt(data.get('last_ingested_at'))}",
+ f" Last run time: {_fmt_dt(data.get('last_run_time'))}",
+ "",
+ ]
+ return lines
+
+
+# =============================================================================
+# COMMAND DISPATCH (orchestrate fetch → JSON or render → print)
+# =============================================================================
+
+def _cmd_summary(args: argparse.Namespace, db_path: Path) -> int:
+ data = _fetch_summary(db_path, args)
+ jobs = _fetch_jobs(db_path, args)
+ job_names = _load_job_names()
+ leader_board = _compute_leader_board(jobs, data, job_names, args.days)
+ data["leader_board"] = leader_board
+ if args.json:
+ print(json.dumps(_json_envelope(data, args, db_path), indent=2, default=str))
+ return 0
+ print("\n".join(_render_summary(data, args, db_path)))
+ return 0
+
+
+def _cmd_jobs(args: argparse.Namespace, db_path: Path) -> int:
+ jobs = _fetch_jobs(db_path, args)
+ if args.json:
+ # Augment each job with schedule projections and pace (mirrors rendered path)
+ for j in jobs:
+ proj = get_job_projections(
+ j["job_id"],
+ avg_estimated_cost=j.get("avg_estimated_cost"),
+ tot_estimated_cost=j["tot_estimated_cost"],
+ runs=j["runs"],
+ first_run=j.get("first_run"),
+ last_run=j.get("last_run"),
+ days_filter=args.days,
+ jobs_json_path=_JOBS_PATH,
+ )
+ j["schedule_display"] = proj.get("schedule_display")
+ j["next_run_at"] = proj.get("next_run_at")
+ j["scheduled_runs_30d"] = proj.get("scheduled_runs_30d")
+ j["scheduled_runs_90d"] = proj.get("scheduled_runs_90d")
+ j["scheduled_runs_1yr"] = proj.get("scheduled_runs_1yr")
+ j["projected_cost_30d"] = proj.get("projected_cost_30d")
+ j["projected_cost_90d"] = proj.get("projected_cost_90d")
+ j["projected_cost_1yr"] = proj.get("projected_cost_1yr")
+ j["trend_projected_cost_30d"] = proj.get("trend_projected_cost_30d")
+ j["trend_projected_cost_90d"] = proj.get("trend_projected_cost_90d")
+ j["trend_projected_cost_1yr"] = proj.get("trend_projected_cost_1yr")
+ j["pace"] = proj.get("pace")
+ j["drift_ratio"] = proj.get("drift_ratio")
+ j["observed_window_days"] = proj.get("observed_window_days")
+ print(json.dumps(_json_envelope(jobs, args, db_path), indent=2, default=str))
+ return 0
+ if not jobs:
+ print(f" No cron jobs found in the last {args.days} days.")
+ return 0
+ job_names = _load_job_names()
+ print("\n".join(_render_jobs(jobs, args, db_path, job_names)))
+ return 0
+
+
+def _cmd_runs(args: argparse.Namespace, db_path: Path) -> int:
+ runs = _fetch_runs(db_path, args)
+ if args.json:
+ print(json.dumps(_json_envelope(runs, args, db_path), indent=2, default=str))
+ return 0
+ if not runs:
+ print(f" No runs found for job '{args.job}' in the last {args.days} days.")
+ return 0
+ print("\n".join(_render_runs(runs, args, db_path)))
+ return 0
+
+
+def _cmd_models(args: argparse.Namespace, db_path: Path) -> int:
+ models = _fetch_models(db_path, args)
+ if args.json:
+ print(json.dumps(_json_envelope(models, args, db_path), indent=2, default=str))
+ return 0
+ if not models:
+ print(f" No model data found in the last {args.days} days.")
+ return 0
+ print("\n".join(_render_models(models, args, db_path)))
+ return 0
+
+
+def _cmd_trends(args: argparse.Namespace, db_path: Path) -> int:
+ trends = _fetch_trends(db_path, args)
+ if args.json:
+ print(json.dumps(_json_envelope(trends, args, db_path), indent=2, default=str))
+ return 0
+ if not trends:
+ print(f" No trend data found in the last {args.days} days.")
+ return 0
+ print("\n".join(_render_trends(trends, args, db_path)))
+ return 0
+
+
+def _cmd_health(args: argparse.Namespace, db_path: Path) -> int:
+ data = _fetch_health(db_path)
+ if args.json:
+ print(json.dumps({"data": data}, indent=2, default=str))
+ return 0
+ print("\n".join(_render_health(data)))
+ return 0
+
+
+def _cmd_sync(args: argparse.Namespace, db_path: Path) -> int:
+ """Backfill historical cron sessions from state.db into facts.db."""
+ from cronalytics import config, scanner
+
+ result = scanner.run_sync(
+ config.STATE_DB,
+ db_path,
+ config.WATERMARK_FILE,
+ )
+ if args.json:
+ print(json.dumps({"data": result}, indent=2, default=str))
+ return 0
+
+ lines: list[str] = []
+ lines.append("")
+ lines.extend(_banner("🔄 Cronalytics Sync", inner_width=_W_STD))
+ total_inserted = result["agent_inserted"] + result["script_inserted"]
+ if total_inserted > 0:
+ lines.append(_banner_line(f"Inserted: {total_inserted} new sessions", _W_STD))
+ if result["agent_inserted"]:
+ lines.append(_banner_line(f" Agent: {result['agent_inserted']}", _W_STD))
+ if result["script_inserted"]:
+ lines.append(_banner_line(f" Script: {result['script_inserted']}", _W_STD))
+ else:
+ lines.append(_banner_line("No new data found.", _W_STD))
+ lines.append(_banner_line(f"Elapsed: {result['elapsed_ms']}ms", _W_STD))
+ lines.append(f" ╚{'═' * _W_STD}╝")
+ print("\n".join(lines))
+ return 0
+
+
+# =============================================================================
+# ARGUMENT PARSING
+# =============================================================================
+
+def _add_standard_flags(parser: argparse.ArgumentParser) -> None:
+ """Attach the flags common to every data subcommand."""
+ parser.add_argument(
+ "--days",
+ type=int,
+ default=30,
+ help="Number of days to look back (default: 30, 0 = all time)",
+ )
+ parser.add_argument(
+ "--outcome",
+ choices=["all", "success", "failure"],
+ default="all",
+ help="Filter by outcome (default: all)",
+ )
+ parser.add_argument(
+ "--mode",
+ choices=["all", "agent", "no_agent"],
+ default="all",
+ help="Filter by job mode (default: all)",
+ )
+ parser.add_argument(
+ "--db",
+ type=Path,
+ default=argparse.SUPPRESS,
+ help="Path to fact DB (default: auto-detected from plugin directory)",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output raw JSON instead of formatted tables",
+ )
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ """Construct and return the top-level ArgumentParser."""
+ parser = argparse.ArgumentParser(
+ description="Cronalytics CLI — dump cron run insights to the terminal",
+ )
+ parser.add_argument(
+ "--db",
+ type=Path,
+ default=None,
+ help="Path to fact DB (default: auto-detected from plugin directory)",
+ )
+ parser.add_argument(
+ "--days",
+ type=int,
+ default=30,
+ help="Number of days to look back (default: 30, 0 = all time)",
+ )
+ parser.add_argument(
+ "--outcome",
+ choices=["all", "success", "failure"],
+ default="all",
+ help="Filter by outcome (default: all)",
+ )
+ parser.add_argument(
+ "--mode",
+ choices=["all", "agent", "no_agent"],
+ default="all",
+ help="Filter by job mode (default: all)",
+ )
+ parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output raw JSON instead of formatted tables",
+ )
+
+ subparsers = parser.add_subparsers(dest="command", required=False)
+
+ for name, help_text in (
+ ("summary", "Aggregate headline summary"),
+ ("jobs", "Per-job breakdown with pace"),
+ ("models", "Per-model estimated cost breakdown"),
+ ("trends", "Daily run-count / estimated cost sparkline"),
+ ("health", "Fact DB health check"),
+ ):
+ p = subparsers.add_parser(name, help=help_text)
+ _add_standard_flags(p)
+
+ runs_parser = subparsers.add_parser("runs", help="Individual runs for a job")
+ _add_standard_flags(runs_parser)
+ runs_parser.add_argument("--job", required=True, help="Job ID filter")
+ runs_parser.add_argument(
+ "--limit",
+ type=int,
+ default=0,
+ help="Max runs to return (0 = no limit, default: 0)",
+ )
+
+ all_parser = subparsers.add_parser("all", help="Run health + summary + jobs + models + trends")
+ _add_standard_flags(all_parser)
+
+ sync_parser = subparsers.add_parser("sync", help="Backfill cron sessions from state.db into fact DB")
+ sync_parser.add_argument(
+ "--db",
+ type=Path,
+ default=argparse.SUPPRESS,
+ help="Path to fact DB (default: auto-detected from plugin directory)",
+ )
+ sync_parser.add_argument(
+ "--json",
+ action="store_true",
+ help="Output raw JSON instead of formatted tables",
+ )
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ """CLI entry point. Parse arguments, resolve DB, dispatch command."""
+ parser = _build_parser()
+
+ try:
+ import argcomplete
+ argcomplete.autocomplete(parser)
+ except ImportError:
+ pass
+
+ args = parser.parse_args(argv)
+
+ db_path = _resolve_db(args.db)
+
+ # Ensure DB schema is up-to-date before any command runs
+ try:
+ get_conn(db_path)
+ except (sqlite3.Error, OSError) as exc:
+ print(f"Error: unable to open fact database at {db_path}: {exc}", file=sys.stderr)
+ return 2
+
+ # Redirect downstream queries to the resolved DB
+ config.FACT_DB = db_path
+
+ # 'all' or bare invocation (no subcommand) → chained full report
+ if args.command == "all" or args.command is None:
+ if args.json:
+ print(
+ "Error: --json is not supported with the 'all' command. "
+ "Use a specific subcommand (e.g., 'jobs --json').",
+ file=sys.stderr,
+ )
+ return 1
+ print("")
+ print("\n".join(_banner("📋 Cronalytics Full Report", inner_width=_W_STD)))
+ ret = 0
+ ret |= _cmd_health(args, db_path)
+ ret |= _cmd_summary(args, db_path)
+ ret |= _cmd_jobs(args, db_path)
+ ret |= _cmd_models(args, db_path)
+ ret |= _cmd_trends(args, db_path)
+ return ret
+
+ # Dispatch to specific command
+ handler_map: dict[str, Callable[[argparse.Namespace, Path], int]] = {
+ "summary": _cmd_summary,
+ "jobs": _cmd_jobs,
+ "runs": _cmd_runs,
+ "models": _cmd_models,
+ "trends": _cmd_trends,
+ "health": _cmd_health,
+ "sync": _cmd_sync,
+ }
+ handler = handler_map[args.command]
+
+ try:
+ return handler(args, db_path)
+ except (sqlite3.Error, OSError, KeyError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ except Exception as exc:
+ # Catch-all for unexpected errors; print a helpful message
+ print(f"Error: unexpected failure: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/config.py b/cronalytics/config.py
similarity index 79%
rename from config.py
rename to cronalytics/config.py
index e6956a2..6c1038c 100644
--- a/config.py
+++ b/cronalytics/config.py
@@ -8,7 +8,16 @@
from pathlib import Path
-from hermes_constants import get_hermes_home
+try:
+ from hermes_constants import get_hermes_home
+ _HERMES_HOME = Path(get_hermes_home())
+except Exception:
+ import os
+ hermes_home = os.environ.get("HERMES_HOME", "")
+ if hermes_home:
+ _HERMES_HOME = Path(hermes_home)
+ else:
+ _HERMES_HOME = Path.home() / ".hermes"
# ---------------------------------------------------------------------------
# Polling / retry schedule
@@ -22,7 +31,7 @@
# Path resolution
# ---------------------------------------------------------------------------
-HERMES_HOME: Path = Path(get_hermes_home())
+HERMES_HOME: Path = _HERMES_HOME
# Operational session store (Hermes core) — the source of truth for cost data.
STATE_DB: Path = HERMES_HOME / "state.db"
@@ -30,7 +39,7 @@
# Plugin-owned fact DB — stores derived cron run data.
# Lives inside the plugin directory so it survives migrations,
# but is .gitignored so the repo stays clean.
-PLUGIN_DIR: Path = Path(__file__).parent.resolve()
+PLUGIN_DIR: Path = HERMES_HOME / "plugins" / "cronalytics"
FACT_DB: Path = PLUGIN_DIR / "facts.db"
# Watermark file for reconciliation scanner (Phase 2).
diff --git a/facts.py b/cronalytics/facts.py
similarity index 86%
rename from facts.py
rename to cronalytics/facts.py
index ac081c3..78f9d74 100644
--- a/facts.py
+++ b/cronalytics/facts.py
@@ -299,10 +299,17 @@ def row_exists(db_path: Path, session_id: str) -> bool:
# Aggregation queries (Phase 3 API)
# ---------------------------------------------------------------------------
-def query_summary(db_path: Path, days: int = 30, outcome: str = "both", mode: str = "all") -> dict[str, Any]:
+def query_summary(db_path: Path, days: int = 30, outcome: str = "all", mode: str = "all") -> dict[str, Any]:
"""Return aggregate stats for cron runs in the last N days (0 = all time)."""
conn = get_conn(db_path)
+ # DB age check: need at least 1.75 full periods for meaningful trend comparison
+ db_age_days = 0.0
+ if days > 0:
+ cursor = conn.execute("SELECT COALESCE(MIN(run_time), 0) FROM cron_runs")
+ min_run_time = cursor.fetchone()[0] or 0.0
+ db_age_days = (time.time() - min_run_time) / 86400
+
conditions: list[str] = []
params: list[Any] = []
if days > 0:
@@ -349,7 +356,7 @@ def query_summary(db_path: Path, days: int = 30, outcome: str = "both", mode: st
params,
)
by_model = [
- {"model": r[0] or "unknown", "runs": r[1], "total_cost": round(r[2], 4) if r[2] is not None else None}
+ {"model": r[0] or "unknown", "runs": r[1], "tot_estimated_cost": round(r[2], 4) if r[2] is not None else None}
for r in cursor.fetchall()
]
@@ -374,9 +381,10 @@ def query_summary(db_path: Path, days: int = 30, outcome: str = "both", mode: st
prev_runs, prev_cost = cursor.fetchone()
prev_info = {
"runs": prev_runs or 0,
- "cost": round(prev_cost, 4) if prev_cost is not None else None,
+ "estimated_cost": round(prev_cost, 4) if prev_cost is not None else None,
}
- if prev_cost is not None and prev_cost > 0 and total_est_cost is not None:
+ if (prev_cost is not None and prev_cost > 0 and total_est_cost is not None
+ and db_age_days >= days * 1.75):
delta = (total_est_cost - prev_cost) / prev_cost
if delta > 0.05:
trend = "↑"
@@ -399,8 +407,9 @@ def query_summary(db_path: Path, days: int = 30, outcome: str = "both", mode: st
return {
"days": days,
"total_runs": total_runs,
- "total_estimated_cost": round(total_est_cost, 4) if total_est_cost is not None else None,
- "total_actual_cost": None, # Suppressed: incomplete data is worse than no data. Re-enable when coverage is reliable.
+ "tot_estimated_cost": round(total_est_cost, 4) if total_est_cost is not None else None,
+ "tot_actual_cost": None, # Suppressed: incomplete data is worse than no data.
+ # Re-enable when coverage is reliable.
"total_tokens": total_tokens,
"total_input_tokens": total_in or 0,
"total_output_tokens": total_out or 0,
@@ -410,15 +419,15 @@ def query_summary(db_path: Path, days: int = 30, outcome: str = "both", mode: st
"avg_duration_seconds": round(avg_dur, 2) if avg_dur is not None else None,
"success_runs": success_runs or 0,
"failure_runs": failure_runs or 0,
- "success_cost": round(success_cost, 4) if success_cost is not None else None,
- "failure_cost": round(failure_cost, 4) if failure_cost is not None else None,
+ "success_estimated_cost": round(success_cost, 4) if success_cost is not None else None,
+ "failure_estimated_cost": round(failure_cost, 4) if failure_cost is not None else None,
"cost_by_model": by_model,
- "previous_period": prev_info if days > 0 else {},
- "trend": trend if days > 0 else "→",
+ "previous_period": prev_info if (days > 0 and db_age_days >= days * 1.75) else {},
+ "trend": trend if (days > 0 and db_age_days >= days * 1.75) else "→",
}
-def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str = "all") -> list[dict[str, Any]]:
+def query_jobs(db_path: Path, days: int = 30, outcome: str = "all", mode: str = "all") -> list[dict[str, Any]]:
"""Return per-job aggregates (0 = all time)."""
conn = get_conn(db_path)
@@ -439,8 +448,8 @@ def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str =
f"""
SELECT job_id,
count(*) AS runs,
- SUM(estimated_cost_usd) AS total_cost,
- AVG(estimated_cost_usd) AS avg_cost,
+ SUM(estimated_cost_usd) AS tot_estimated_cost,
+ AVG(estimated_cost_usd) AS avg_estimated_cost,
MAX(run_time) AS last_run,
COALESCE(MIN(run_time), 0) AS first_run,
MAX(model) AS last_model,
@@ -452,12 +461,12 @@ def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str =
AVG(duration_seconds) AS avg_duration,
count(CASE WHEN success = 1 THEN 1 END) AS success_runs,
count(CASE WHEN success = 0 THEN 1 END) AS failure_runs,
- SUM(CASE WHEN success = 1 THEN estimated_cost_usd END) AS success_cost,
- SUM(CASE WHEN success = 0 THEN estimated_cost_usd END) AS failure_cost,
+ SUM(CASE WHEN success = 1 THEN estimated_cost_usd END) AS success_estimated_cost,
+ SUM(CASE WHEN success = 0 THEN estimated_cost_usd END) AS failure_estimated_cost,
MAX(job_mode) AS job_mode
FROM cron_runs{where}
GROUP BY job_id
- ORDER BY total_cost DESC
+ ORDER BY tot_estimated_cost DESC
""",
params,
)
@@ -465,8 +474,8 @@ def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str =
{
"job_id": r[0],
"runs": r[1],
- "total_cost": round(r[2], 4) if r[2] is not None else None,
- "avg_cost": round(r[3], 4) if r[3] is not None else None,
+ "tot_estimated_cost": round(r[2], 4) if r[2] is not None else None,
+ "avg_estimated_cost": round(r[3], 4) if r[3] is not None else None,
"last_run": r[4],
"first_run": r[5],
"last_model": r[6] or "unknown",
@@ -479,8 +488,8 @@ def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str =
"avg_duration": round(r[12], 2) if r[12] is not None else None,
"success_runs": r[13] or 0,
"failure_runs": r[14] or 0,
- "success_cost": round(r[15], 4) if r[15] is not None else None,
- "failure_cost": round(r[16], 4) if r[16] is not None else None,
+ "success_estimated_cost": round(r[15], 4) if r[15] is not None else None,
+ "failure_estimated_cost": round(r[16], 4) if r[16] is not None else None,
"job_mode": r[17] or "agent",
}
for r in cursor.fetchall()
@@ -488,14 +497,14 @@ def query_jobs(db_path: Path, days: int = 30, outcome: str = "both", mode: str =
def query_job_runs(
- db_path: Path, job_id: str, limit: int = 50, days: int = 0,
- outcome: str = "both", sort_key: str = "run_time", sort_dir: str = "desc",
+ db_path: Path, job_id: str, limit: int = 0, days: int = 0,
+ outcome: str = "all", sort_key: str = "run_time", sort_dir: str = "desc",
mode: str = "all",
) -> list[dict[str, Any]]:
"""Return individual run history for a specific job (0 = all time).
- Supports filtering by outcome (both/success/failure) and custom sorting:
- run_time, estimated_cost_usd, duration_seconds, success, model.
+ Supports filtering by outcome (all/success/failure) and custom sorting:
+ run_time, estimated_cost, duration_seconds, success, model.
"""
conn = get_conn(db_path)
conditions = ["job_id = ?"]
@@ -516,8 +525,13 @@ def query_job_runs(
where = " WHERE " + " AND ".join(conditions)
# Safe column whitelist — only allow known sortable columns
- safe_cols = {"run_time", "estimated_cost_usd", "duration_seconds", "success", "model", "input_tokens"}
- order_col = sort_key if sort_key in safe_cols else "run_time"
+ safe_cols = {"run_time", "estimated_cost_usd", "duration_seconds", "success", "model", "input_tokens", "job_mode"}
+ sort_col_map = {"estimated_cost": "estimated_cost_usd"}
+ order_col = (
+ sort_col_map.get(sort_key, sort_key)
+ if sort_key in safe_cols or sort_key in sort_col_map
+ else "run_time"
+ )
order_dir = "DESC" if sort_dir == "desc" else "ASC"
cursor = conn.execute(
@@ -532,16 +546,16 @@ def query_job_runs(
FROM cron_runs
{where}
ORDER BY {order_col} {order_dir}
- LIMIT ?
+ {("LIMIT ?" if limit > 0 else "")}
""",
- params + [limit],
+ (params + [limit]) if limit > 0 else params,
)
cols = [
"session_id", "job_id", "run_time", "ended_at",
"duration_seconds", "model",
"input_tokens", "output_tokens", "reasoning_tokens",
"cache_read_tokens", "cache_write_tokens",
- "estimated_cost_usd", "actual_cost_usd",
+ "estimated_cost", "actual_cost",
"cost_status", "billing_provider",
"end_reason", "success", "job_mode",
]
@@ -583,7 +597,7 @@ def query_health(db_path: Path) -> dict[str, Any]:
}
-def query_models(db_path: Path, days: int = 30, outcome: str = "both", mode: str = "all") -> list[dict[str, Any]]:
+def query_models(db_path: Path, days: int = 30, outcome: str = "all", mode: str = "all") -> list[dict[str, Any]]:
"""Return per-model usage aggregates (0 = all time)."""
conn = get_conn(db_path)
@@ -604,14 +618,14 @@ def query_models(db_path: Path, days: int = 30, outcome: str = "both", mode: str
f"""
SELECT model,
count(*) AS runs,
- SUM(estimated_cost_usd) AS total_cost,
- AVG(estimated_cost_usd) AS avg_cost,
+ SUM(estimated_cost_usd) AS tot_estimated_cost,
+ AVG(estimated_cost_usd) AS avg_estimated_cost,
COALESCE(SUM(input_tokens), 0) AS total_input,
COALESCE(SUM(output_tokens), 0) AS total_output,
MAX(run_time) AS last_run
FROM cron_runs{where}
GROUP BY model
- ORDER BY total_cost DESC
+ ORDER BY tot_estimated_cost DESC
""",
params,
)
@@ -619,8 +633,8 @@ def query_models(db_path: Path, days: int = 30, outcome: str = "both", mode: str
{
"model": r[0] or "unknown",
"runs": r[1],
- "total_cost": round(r[2], 4) if r[2] is not None else None,
- "avg_cost": round(r[3], 4) if r[3] is not None else None,
+ "tot_estimated_cost": round(r[2], 4) if r[2] is not None else None,
+ "avg_estimated_cost": round(r[3], 4) if r[3] is not None else None,
"total_input_tokens": r[4],
"total_output_tokens": r[5],
"last_run": r[6],
@@ -629,7 +643,7 @@ def query_models(db_path: Path, days: int = 30, outcome: str = "both", mode: str
]
-def query_trends(db_path: Path, days: int = 30, outcome: str = "both", mode: str = "all") -> list[dict[str, Any]]:
+def query_trends(db_path: Path, days: int = 30, outcome: str = "all", mode: str = "all") -> list[dict[str, Any]]:
"""Return daily cost and run-count trend (0 = all time)."""
conn = get_conn(db_path)
@@ -650,7 +664,7 @@ def query_trends(db_path: Path, days: int = 30, outcome: str = "both", mode: str
f"""
SELECT date(run_time, 'unixepoch') AS day,
count(*) AS runs,
- SUM(estimated_cost_usd) AS cost,
+ SUM(estimated_cost_usd) AS estimated_cost,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens
FROM cron_runs{where}
@@ -663,7 +677,7 @@ def query_trends(db_path: Path, days: int = 30, outcome: str = "both", mode: str
{
"day": r[0],
"runs": r[1],
- "cost": round(r[2], 4) if r[2] is not None else None,
+ "estimated_cost": round(r[2], 4) if r[2] is not None else None,
"input_tokens": r[3],
"output_tokens": r[4],
}
diff --git a/ingester.py b/cronalytics/ingester.py
similarity index 100%
rename from ingester.py
rename to cronalytics/ingester.py
diff --git a/logger.py b/cronalytics/logger.py
similarity index 100%
rename from logger.py
rename to cronalytics/logger.py
diff --git a/scanner.py b/cronalytics/scanner.py
similarity index 97%
rename from scanner.py
rename to cronalytics/scanner.py
index 01eadce..4980c1b 100644
--- a/scanner.py
+++ b/cronalytics/scanner.py
@@ -56,7 +56,7 @@ def _read_watermark(path: Path) -> Watermark:
return {"last_ended_at": 0.0, "last_sync": None, "rows_synced": 0}
try:
with open(path, encoding="utf-8") as fh:
- return json.load(fh)
+ return json.load(fh) # type: ignore[no-any-return]
except (OSError, json.JSONDecodeError):
logger.error("[scanner] Corrupt watermark, resetting", exc_info=True)
return {"last_ended_at": 0.0, "last_sync": None, "rows_synced": 0}
@@ -218,10 +218,10 @@ def run_sync(
Returns a summary dict with counts and timestamps.
"""
wm = _read_watermark(watermark_path)
- since = float(wm.get("last_ended_at", 0.0))
+ since = float(wm.get("last_ended_at") or 0.0)
logger.info("[scanner] Starting sync since ended_at=%s", since)
- started = time.time()
+ started = time.perf_counter()
# --- Track A: Agent jobs from state.db ---
rows = _fetch_new_sessions(state_db, since)
@@ -257,7 +257,7 @@ def run_sync(
except Exception:
logger.error("[scanner] Script sync failed", exc_info=True)
- elapsed = time.time() - started
+ elapsed = time.perf_counter() - started
logger.info(
"[scanner] Sync complete: agent=%d/%d, script=%d/%d, %.2fs",
agent_inserted, agent_skipped,
diff --git a/schedule.py b/cronalytics/schedule.py
similarity index 92%
rename from schedule.py
rename to cronalytics/schedule.py
index ddf5ba6..d2ed817 100644
--- a/schedule.py
+++ b/cronalytics/schedule.py
@@ -71,8 +71,8 @@ def _count_occurrences(
def get_job_projections(
job_id: str,
- avg_cost: float | None,
- total_cost: float | None,
+ avg_estimated_cost: float | None,
+ tot_estimated_cost: float | None,
runs: int,
first_run: float | None,
last_run: float | None,
@@ -84,8 +84,8 @@ def get_job_projections(
Args:
job_id: Stable job ID (matches jobs.json `id`).
- avg_cost: Average cost per run from fact DB.
- total_cost: Total cost for the window (for trend pacing).
+ avg_estimated_cost: Average estimated cost per run from fact DB.
+ tot_estimated_cost: Total estimated cost for the window (for trend pacing).
runs: Number of runs in the observation window.
first_run: Earliest run_time (unix epoch).
last_run: Latest run_time (unix epoch).
@@ -108,7 +108,7 @@ def get_job_projections(
minutes: int | None = None
if job_def:
- sched = job_def.get("schedule", {})
+ sched = job_def.get("schedule") or {}
kind = sched.get("kind")
expr = sched.get("expr")
minutes = sched.get("minutes")
@@ -124,7 +124,7 @@ def get_job_projections(
for d in horizon_days:
sr = _count_occurrences(kind, expr, minutes, now, now + timedelta(days=d))
scheduled_runs[f"{d}d"] = sr
- ac = avg_cost if avg_cost is not None else 0.0
+ ac = avg_estimated_cost if avg_estimated_cost is not None else 0.0
nominal_proj[f"{d}d"] = round(ac * sr, 4) if sr is not None else None
else:
for d in horizon_days:
@@ -141,8 +141,8 @@ def get_job_projections(
observed_window = 0.0
trend_proj: dict[str, float | None] = {}
- if observed_window > 0 and runs > 0 and total_cost is not None:
- daily_cost = total_cost / observed_window
+ if observed_window > 0 and runs > 0 and tot_estimated_cost is not None:
+ daily_cost = tot_estimated_cost / observed_window
for d in horizon_days:
trend_proj[f"{d}d"] = round(daily_cost * d, 4)
else:
diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js
index c21eefa..e4652dc 100644
--- a/dashboard/dist/index.js
+++ b/dashboard/dist/index.js
@@ -1,5 +1,5 @@
(() => {
- // src/lib/sdk.js
+ // dashboard/src/lib/sdk.js
var SDK = window.__HERMES_PLUGIN_SDK__;
var PLUGINS = window.__HERMES_PLUGINS__;
if (!SDK || !PLUGINS) {
@@ -10,7 +10,7 @@
var fetchJSON = SDK.fetchJSON;
var { Card, CardHeader, CardTitle, CardContent, Badge, Button } = SDK.components;
- // src/components/ErrorBoundary.js
+ // dashboard/src/components/ErrorBoundary.js
var PluginErrorBoundary = class extends React.Component {
constructor(props) {
super(props);
@@ -26,28 +26,20 @@
{
style: {
padding: "2rem",
- color: "var(--color-destructive, #ef4444)",
textAlign: "center",
- fontFamily: "var(--theme-font-mono, monospace)"
+ fontFamily: "var(--theme-font-mono, monospace)",
+ color: "var(--foreground)"
}
},
- React.createElement(
- "div",
- { style: { fontWeight: 700, marginBottom: "0.5rem" } },
- "Cronalytics Error"
- ),
- React.createElement(
- "div",
- { style: { fontSize: "0.85rem", opacity: 0.8 } },
- "Something went wrong. Please refresh or contact support."
- )
+ React.createElement("h3", { style: { marginBottom: "0.5rem" } }, "Cronalytics Error"),
+ React.createElement("p", { style: { opacity: 0.7 } }, "Something went wrong. Please refresh or contact support.")
);
}
return this.props.children;
}
};
- // src/lib/validate.js
+ // dashboard/src/lib/validate.js
var IS_DEV = (() => {
try {
return typeof process !== "undefined" && process.env && false;
@@ -85,7 +77,7 @@
function validateSummary(d) {
assertType("/summary", d, Object);
assertType("/summary", d.total_runs, "number", "total_runs");
- assertType("/summary", d.total_estimated_cost, "number", "total_estimated_cost");
+ assertType("/summary", d.tot_estimated_cost, "number", "tot_estimated_cost");
assertType("/summary", d.total_tokens, "number", "total_tokens");
assertType("/summary", d.success_runs, "number", "success_runs");
assertType("/summary", d.failure_runs, "number", "failure_runs");
@@ -99,7 +91,7 @@
d.jobs.forEach((j, i) => {
assertType("/jobs", j.job_id, "string", `jobs[${i}].job_id`);
assertType("/jobs", j.runs, "number", `jobs[${i}].runs`);
- assertType("/jobs", j.total_cost, "number", `jobs[${i}].total_cost`);
+ assertType("/jobs", j.tot_estimated_cost, "number", `jobs[${i}].tot_estimated_cost`);
assertType("/jobs", j.projections, "object", `jobs[${i}].projections`);
});
}
@@ -109,11 +101,17 @@
assertType("/jobs/:id/runs", d.job_id, "string", "job_id");
assertType("/jobs/:id/runs", d.limit, "number", "limit");
assertType("/jobs/:id/runs", d.runs, Array, "runs");
+ if (d.total_runs != null) {
+ assertType("/jobs/:id/runs", d.total_runs, "number", "total_runs");
+ }
+ if (d.more_available != null) {
+ assertType("/jobs/:id/runs", d.more_available, "boolean", "more_available");
+ }
if (IS_DEV && Array.isArray(d.runs)) {
d.runs.forEach((r, i) => {
assertType("/jobs/:id/runs", r.session_id, "string", `runs[${i}].session_id`);
assertType("/jobs/:id/runs", r.run_time, "number", `runs[${i}].run_time`);
- assertType("/jobs/:id/runs", r.estimated_cost_usd, "number", `runs[${i}].estimated_cost_usd`);
+ assertType("/jobs/:id/runs", r.estimated_cost, "number", `runs[${i}].estimated_cost`);
});
}
}
@@ -124,7 +122,7 @@
d.models.forEach((m, i) => {
assertType("/models", m.model, "string", `models[${i}].model`);
assertType("/models", m.runs, "number", `models[${i}].runs`);
- assertType("/models", m.total_cost, "number", `models[${i}].total_cost`);
+ assertType("/models", m.tot_estimated_cost, "number", `models[${i}].tot_estimated_cost`);
});
}
}
@@ -134,7 +132,7 @@
if (IS_DEV && Array.isArray(d.trend)) {
d.trend.forEach((t, i) => {
assertType("/trends", t.day, "string", `trend[${i}].day`);
- assertType("/trends", t.cost, "number", `trend[${i}].cost`);
+ assertType("/trends", t.estimated_cost, "number", `trend[${i}].estimated_cost`);
assertType("/trends", t.runs, "number", `trend[${i}].runs`);
});
}
@@ -150,7 +148,7 @@
return void 0;
}
- // src/hooks/useApi.js
+ // dashboard/src/hooks/useApi.js
function useApi(path) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
@@ -191,8 +189,65 @@
return { isOpen, open, close };
}
- // src/components/Modal.js
+ // dashboard/src/i18n/index.js
+ var CATALOGS = {};
+ function registerCatalog(lang, messages) {
+ CATALOGS[lang] = messages;
+ }
+ function getSDK() {
+ return window.__HERMES_PLUGIN_SDK__ || {};
+ }
+ function getLocale() {
+ const sdk = getSDK();
+ let code = "en";
+ if (sdk.useI18n) {
+ try {
+ code = sdk.useI18n().locale || "en";
+ } catch {
+ }
+ } else {
+ code = navigator.language || "en";
+ }
+ if (CATALOGS[code]) return code;
+ const base = code.split("-")[0];
+ if (CATALOGS[base]) return base;
+ return "en";
+ }
+ function resolve(key, catalog) {
+ const parts = key.split(".");
+ let node = catalog;
+ for (const p of parts) {
+ if (node == null || typeof node !== "object") return void 0;
+ node = node[p];
+ }
+ return typeof node === "string" ? node : void 0;
+ }
+ function interpolate(template, vars) {
+ if (!vars || typeof template !== "string") return template;
+ return template.replace(/\{(\w+)\}/g, (_match, name) => {
+ return vars[name] !== void 0 ? String(vars[name]) : _match;
+ });
+ }
+ function useCronalyticsI18n() {
+ const locale = getLocale();
+ const catalog = CATALOGS[locale] || CATALOGS["en"] || {};
+ return function t(key, fallbackOrVars, maybeVars) {
+ let fallback;
+ let vars;
+ if (typeof fallbackOrVars === "string") {
+ fallback = fallbackOrVars;
+ vars = maybeVars;
+ } else {
+ fallback = key;
+ vars = fallbackOrVars;
+ }
+ return interpolate(resolve(key, catalog) ?? fallback, vars);
+ };
+ }
+
+ // dashboard/src/components/Modal.js
function Modal({ isOpen, onClose, children, maxWidth }) {
+ const t = useCronalyticsI18n();
const backdropRef = useRef(null);
const [bounds, setBounds] = useState(null);
useEffect(() => {
@@ -258,7 +313,7 @@
"button",
{
type: "button",
- "aria-label": "Close",
+ "aria-label": t("modal.close", "Close"),
onClick: onClose,
style: {
position: "absolute",
@@ -292,7 +347,7 @@
);
}
- // src/components/DaySelector.js
+ // dashboard/src/components/DaySelector.js
var PRESETS = [
{ label: "7D", value: 7 },
{ label: "30D", value: 30 },
@@ -300,6 +355,7 @@
];
var MAX_DAYS = 365;
function DaySelector({ selected, onChange, label = null }) {
+ const t = useCronalyticsI18n();
const [custom, setCustom] = useState("");
const applyCustom = () => {
const v = parseInt(custom, 10);
@@ -367,9 +423,9 @@
size: "sm",
outlined: true,
onClick: applyCustom,
- title: "Apply custom days"
+ title: t("day_selector.apply_custom", "Apply custom days")
},
- "Go"
+ t("day_selector.go", "Go")
)
)
];
@@ -396,13 +452,14 @@
return elements;
}
- // src/components/OutcomeToggle.js
- var OPTIONS = [
- { label: "All", value: "both" },
- { label: "Success", value: "success" },
- { label: "Failure", value: "failure" }
- ];
+ // dashboard/src/components/OutcomeToggle.js
function OutcomeToggle({ selected, onChange, label }) {
+ const t = useCronalyticsI18n();
+ const OPTIONS = [
+ { label: t("outcome_toggle.all", "All"), value: "all" },
+ { label: t("outcome_toggle.success", "Success"), value: "success" },
+ { label: t("outcome_toggle.failure", "Failure"), value: "failure" }
+ ];
return React.createElement(
"div",
{ style: { display: "flex", gap: "0.5rem", alignItems: "center" } },
@@ -436,13 +493,14 @@
);
}
- // src/components/ModeToggle.js
- var OPTIONS2 = [
- { label: "All", value: "all" },
- { label: "Agent", value: "agent" },
- { label: "No Agent", value: "no_agent" }
- ];
+ // dashboard/src/components/ModeToggle.js
function ModeToggle({ selected, onChange, label }) {
+ const t = useCronalyticsI18n();
+ const OPTIONS = [
+ { label: t("mode_toggle.all", "All"), value: "all" },
+ { label: t("mode_toggle.agent", "Agent"), value: "agent" },
+ { label: t("mode_toggle.no_agent", "No Agent"), value: "no_agent" }
+ ];
return React.createElement(
"div",
{ style: { display: "flex", gap: "0.5rem", alignItems: "center" } },
@@ -460,7 +518,7 @@
},
label
) : null,
- ...OPTIONS2.map(
+ ...OPTIONS.map(
(o) => React.createElement(
Button,
{
@@ -476,7 +534,7 @@
);
}
- // src/lib/formatters.js
+ // dashboard/src/lib/formatters.js
function fmtCost(n) {
if (n == null) return "\u2014";
if (n === 0) return "$0.00";
@@ -545,29 +603,30 @@
return "transparent";
}
- // src/components/JobDetailView.js
- var COLUMNS = [
- { label: "Time", key: "run_time", align: "left" },
- { label: "Cost", key: "estimated_cost_usd", align: "right" },
- { label: "Duration", key: "duration_seconds", align: "right" },
- { label: "Tokens", key: "input_tokens", align: "right" },
- { label: "Model", key: "model", align: "left" },
- { label: "Mode", key: "job_mode", align: "center" },
- { label: "Result", key: "success", align: "center" }
- ];
- function tokTotal(r) {
- return (r.input_tokens || 0) + (r.output_tokens || 0) + (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
- }
+ // dashboard/src/components/JobDetailView.js
function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir }) {
+ const t = useCronalyticsI18n();
const [sKey, setSKey] = useState(sortKey);
const [sDir, setSDir] = useState(sortDir);
- const path = `/api/plugins/cronalytics/jobs/${encodeURIComponent(jobId)}/runs?days=${days}&outcome=${outcome}&sort_key=${sKey}&sort_dir=${sDir}&limit=200`;
+ const COLUMNS = [
+ { label: t("job_detail.time", "Time"), key: "run_time", align: "left", width: "10rem" },
+ { label: t("job_detail.est_cost", "Est Cost"), key: "estimated_cost", align: "right", width: "6rem" },
+ { label: t("job_detail.duration", "Duration"), key: "duration_seconds", align: "right", width: "5rem" },
+ { label: t("summary.tokens", "Tokens"), key: "input_tokens", align: "right", width: "6rem" },
+ { label: t("model_breakdown.model", "Model"), key: "model", align: "left", width: "auto" },
+ { label: t("job_detail.mode", "Mode"), key: "job_mode", align: "center", width: "4rem" },
+ { label: t("job_detail.result", "Result"), key: "success", align: "center", width: "3.5rem" }
+ ];
+ function tokTotal(r) {
+ return (r.input_tokens || 0) + (r.output_tokens || 0) + (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
+ }
+ const path = `/api/plugins/cronalytics/jobs/${encodeURIComponent(jobId)}/runs?days=${days}&outcome=${outcome}&sort_key=${sKey}&sort_dir=${sDir}&limit=250`;
const runs = useApi(path);
const sortedRuns = runs.data && runs.data.runs ? [...runs.data.runs].sort((a, b) => {
const dir = sDir === "desc" ? -1 : 1;
const av = a[sKey], bv = b[sKey];
if (sKey === "input_tokens") return dir * (tokTotal(a) - tokTotal(b));
- if (sKey === "run_time" || sKey === "estimated_cost_usd" || sKey === "duration_seconds") return dir * (av - bv);
+ if (sKey === "run_time" || sKey === "estimated_cost" || sKey === "duration_seconds") return dir * (av - bv);
if (sKey === "success") return dir * ((av ? 1 : 0) - (bv ? 1 : 0));
if (av == null || av === "") return 1;
if (bv == null || bv === "") return -1;
@@ -614,11 +673,11 @@
fontFamily: "var(--theme-font-mono, monospace)"
}
},
- runs.data && runs.data.runs ? runs.data.runs.length + " run" + (runs.data.runs.length === 1 ? "" : "s") : ""
+ runs.data && runs.data.runs ? runs.data.runs.length + " " + t("job_detail.run", "run") + (runs.data.runs.length === 1 ? "" : "s") : ""
)
)
),
- runs.loading ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, "Loading runs...") : runs.error ? React.createElement("div", { style: { color: "#ef4444", padding: "1rem 0" } }, "Error: " + runs.error) : !sortedRuns.length ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, "No runs captured for this job.") : React.createElement(
+ runs.loading ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, t("job_detail.loading", "Loading runs...")) : runs.error ? React.createElement("div", { style: { color: "#ef4444", padding: "1rem 0" } }, t("job_detail.error_prefix", "Error: ") + runs.error) : !sortedRuns.length ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, t("job_detail.no_runs", "No runs captured for this job.")) : React.createElement(
React.Fragment,
null,
React.createElement(
@@ -655,10 +714,17 @@
borderBottom: "2px solid var(--color-border)",
cursor: "pointer",
userSelect: "none",
- whiteSpace: "nowrap"
+ whiteSpace: "nowrap",
+ width: col.width || "auto"
}
},
- col.label + (isActive ? sDir === "desc" ? " \u2193" : " \u2191" : "")
+ [
+ col.label,
+ React.createElement("span", {
+ key: "arrow",
+ style: { display: "inline-block", width: "1em", marginLeft: "0.15rem", textAlign: "center" }
+ }, isActive ? sDir === "desc" ? "\u2193" : "\u2191" : "")
+ ]
);
})
)
@@ -687,40 +753,69 @@
key: r.session_id,
style: { borderBottom: "1px solid rgba(255,255,255,0.04)" }
},
- React.createElement("td", { style: { padding: "0.4rem 0.35rem", whiteSpace: "nowrap" } }, fmtTime(r.run_time)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtCost(r.estimated_cost_usd)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtDuration(r.duration_seconds)),
+ React.createElement("td", { style: { padding: "0.4rem 0.35rem", whiteSpace: "nowrap", width: "10rem" } }, fmtTime(r.run_time)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 1.85rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", width: "6rem" } }, fmtCost(r.estimated_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 1.35rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", width: "5rem" } }, fmtDuration(r.duration_seconds)),
React.createElement(
"td",
- { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", whiteSpace: "nowrap" } },
+ { style: { textAlign: "right", padding: "0.4rem 1.35rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", whiteSpace: "nowrap", width: "6rem" } },
(() => {
const total = tokTotal(r);
if (total === 0) return "\u2014";
return fmtCompact(total);
})()
),
- React.createElement("td", { style: { padding: "0.4rem 0.35rem" } }, r.model || "\u2014"),
+ React.createElement("td", { style: { padding: "0.4rem 0.35rem", overflow: "hidden", textOverflow: "ellipsis", width: "auto" } }, r.model || "\u2014"),
React.createElement(
"td",
- { style: { textAlign: "center", padding: "0.4rem 0.35rem" } },
- r.job_mode === "no_agent" ? React.createElement(Badge, { size: "xs", style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 } }, "No agent") : React.createElement("span", { style: { fontSize: "0.65rem", opacity: 0.45 } }, "Agent")
+ { style: { textAlign: "center", padding: "0.4rem 0.35rem", width: "4rem" } },
+ r.job_mode === "no_agent" ? React.createElement(Badge, { size: "xs", style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 } }, t("job_breakdown.mode_no_agent", "No agent")) : React.createElement("span", { style: { fontSize: "0.65rem", opacity: 0.45 } }, t("mode_toggle.agent", "Agent"))
),
React.createElement(
"td",
- { style: { textAlign: "center", padding: "0.4rem 0.35rem" } },
+ { style: { textAlign: "center", padding: "0.4rem 0.35rem", width: "3.5rem" } },
r.success ? React.createElement("span", { style: { color: "#22c55e" } }, "\u2713") : React.createElement("span", { style: { color: "#ef4444" } }, "\u2717")
)
)
)
)
)
+ ),
+ runs.data && runs.data.more_available && React.createElement(
+ "div",
+ {
+ style: {
+ marginTop: "0.75rem",
+ padding: "0.5rem 0.75rem",
+ fontSize: "0.72rem",
+ opacity: 0.7,
+ background: "rgba(255,255,255,0.03)",
+ borderRadius: "0.35rem",
+ lineHeight: 1.5
+ }
+ },
+ t("job_detail.showing", "Showing "),
+ runs.data.runs.length,
+ t("job_detail.of", " of "),
+ runs.data.total_runs.toLocaleString(),
+ " ",
+ t("job_detail.runs_plural", "runs"),
+ ". ",
+ t("job_detail.use_cli", "Use "),
+ React.createElement(
+ "code",
+ { style: { fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.9 } },
+ "cronalytics runs --job " + jobId + " --days " + (days === 0 ? "0" : days)
+ ),
+ t("job_detail.for_full_history", " for full history.")
)
)
);
}
- // src/components/HeroBanner.js
+ // dashboard/src/components/HeroBanner.js
function HeroBanner() {
+ const t = useCronalyticsI18n();
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem("cronalytics:hero:collapsed") === "1";
@@ -751,13 +846,13 @@
cursor: "pointer"
},
onClick: toggle,
- title: "Expand hero banner"
+ title: t("hero.expand_tooltip", "Expand hero banner")
},
React.createElement(
"div",
{ style: { display: "flex", alignItems: "baseline", gap: "0.5rem" } },
- React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", fontWeight: 700, opacity: 0.8, letterSpacing: "0.08em", textTransform: "uppercase" } }, "CRONALYTICS"),
- React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.65rem", opacity: 0.5, letterSpacing: "0.1em", textTransform: "uppercase" } }, "Observe. Measure. Optimize.")
+ React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", fontWeight: 700, opacity: 0.8, letterSpacing: "0.08em", textTransform: "uppercase" } }, t("hero.title", "CRONALYTICS")),
+ React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.65rem", opacity: 0.5, letterSpacing: "0.1em", textTransform: "uppercase" } }, t("hero.tagline", "Observe. Measure. Optimize."))
),
React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.5 } }, "\u25BC")
);
@@ -776,7 +871,7 @@
// Collapse toggle
React.createElement("button", {
onClick: toggle,
- title: "Collapse hero banner",
+ title: t("hero.collapse_tooltip", "Collapse hero banner"),
style: {
position: "absolute",
top: 4,
@@ -801,8 +896,8 @@
marginBottom: "0.15rem"
}
},
- "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/",
- React.createElement("i", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.65rem" } }, "(noun)")
+ t("hero.pronunciation", "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/"),
+ React.createElement("i", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.65rem" } }, t("hero.noun", "(noun)"))
),
React.createElement("div", {
style: {
@@ -813,7 +908,7 @@
maxWidth: "42rem",
marginBottom: "0.15rem"
}
- }, "1. Cron analytics and observability."),
+ }, t("hero.definition_1", "1. Cron analytics and observability.")),
React.createElement("div", {
style: {
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
@@ -823,7 +918,7 @@
maxWidth: "42rem",
marginBottom: "0.35rem"
}
- }, "2. The dashboard for agentic automations in Hermes."),
+ }, t("hero.definition_2", "2. The dashboard for agentic automations in Hermes.")),
React.createElement("div", {
style: {
fontFamily: "var(--theme-font-mono, monospace)",
@@ -833,11 +928,11 @@
textTransform: "uppercase",
opacity: 0.6
}
- }, "Observe. Measure. Optimize.")
+ }, t("hero.tagline", "Observe. Measure. Optimize."))
);
}
- // src/lib/icons.js
+ // dashboard/src/lib/icons.js
function CpuIcon(size) {
return React.createElement(
"svg",
@@ -1024,11 +1119,12 @@
);
}
- // src/components/SummaryBoard.js
+ // dashboard/src/components/SummaryBoard.js
function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick, onTokensClick, onPaceClick }) {
+ const t = useCronalyticsI18n();
const s = summary || {};
const runPct = s.previous_period && s.previous_period.runs != null && s.previous_period.runs !== 0 ? (s.total_runs - s.previous_period.runs) / s.previous_period.runs * 100 : null;
- const costPct = s.previous_period && s.previous_period.cost != null && s.previous_period.cost !== 0 ? (s.total_estimated_cost - s.previous_period.cost) / s.previous_period.cost * 100 : null;
+ const costPct = s.previous_period && s.previous_period.estimated_cost != null && s.previous_period.estimated_cost !== 0 ? (s.tot_estimated_cost - s.previous_period.estimated_cost) / s.previous_period.estimated_cost * 100 : null;
const cardHover = {
onMouseEnter: (e) => {
e.currentTarget.style.boxShadow = "0 0 0 1px rgba(255,255,255,0.18), 0 0 22px rgba(255,255,255,0.10), 0 0 6px rgba(255,255,255,0.15)";
@@ -1066,7 +1162,7 @@
// Job Runs
React.createElement(
"div",
- cardProps(onRunsClick, "Job Runs details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onRunsClick, t("summary.job_runs", "Job Runs") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1077,7 +1173,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, ZapIcon(14)),
- React.createElement(CardTitle, null, "Job Runs"),
+ React.createElement(CardTitle, null, t("summary.job_runs", "Job Runs")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1093,8 +1189,8 @@
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.1rem" } },
- "vs prior ",
- days === 0 ? "period" : days + "d"
+ t("summary.vs_prior", "vs prior") + " ",
+ days === 0 ? t("summary.period", "period") : days + "d"
)
)
)
@@ -1102,7 +1198,7 @@
// Cost
React.createElement(
"div",
- cardProps(onCostClick, outcome === "failure" ? "Wasted cost details" : "Cost details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onCostClick, outcome === "failure" ? t("summary.estimated", "Est") + " " + t("summary.wasted", "Wasted") + " cost details" : t("summary.estimated", "Est") + " cost details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1113,7 +1209,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, BanknoteIcon(14)),
- React.createElement(CardTitle, null, outcome === "failure" ? "Wasted" : "Cost"),
+ React.createElement(CardTitle, null, outcome === "failure" ? t("summary.wasted", "Wasted") : t("summary.cost", "Cost")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1122,25 +1218,30 @@
null,
React.createElement(
"div",
- { style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: outcome === "failure" ? "#ef4444" : "#f5a623" } },
- fmtCost(s.total_estimated_cost)
+ { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement(
+ "div",
+ { style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: outcome === "failure" ? "#ef4444" : "#f5a623" } },
+ fmtCost(s.tot_estimated_cost)
+ ),
+ React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.95, fontFamily: "var(--theme-font-mono, monospace)", background: "rgba(245,166,35,0.12)", border: "1px solid rgba(245,166,35,0.25)", borderRadius: "0.25rem", padding: "0.05rem 0.4rem" } }, t("summary.estimated", "Estimated"))
),
React.createElement(
"div",
- { style: { display: "flex", alignItems: "center", gap: "0.35rem", marginTop: "0.2rem", fontSize: "1.05rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: costPct != null ? costPct > 0 ? "#ef4444" : "#4ade80" : null } },
+ { style: { display: "flex", alignItems: "center", gap: "0.35rem", marginTop: "0.2rem", fontSize: "1.05rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: costPct != null ? (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "%" : "\u2014" } },
costPct != null ? (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "%" : "\u2014"
),
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.1rem" } },
- "vs prior ",
- days === 0 ? "period" : days + "d"
+ t("summary.vs_prior", "vs prior") + " ",
+ days === 0 ? t("summary.period", "period") : days + "d"
),
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.3rem", borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: "0.25rem" } },
- "Actual: ",
- s.total_actual_cost != null ? fmtCost(s.total_actual_cost) : "\u2014"
+ t("summary.actual", "Actual") + ": ",
+ s.tot_actual_cost != null ? fmtCost(s.tot_actual_cost) : "\u2014"
),
React.createElement(
"div",
@@ -1148,7 +1249,7 @@
React.createElement("span", { style: { color: "#4ade80" } }, "\u2713 ", s.success_runs || 0),
" \xB7 ",
React.createElement("span", { style: { color: (s.failure_runs || 0) > 0 ? "#ef4444" : null } }, "\u2717 ", s.failure_runs || 0),
- s.failure_cost != null && s.failure_cost > 0 ? " (" + fmtCost(s.failure_cost) + " wasted)" : ""
+ s.failure_estimated_cost != null && s.failure_estimated_cost > 0 ? " (" + fmtCost(s.failure_estimated_cost) + " " + t("summary.wasted", "wasted") + ")" : ""
)
)
)
@@ -1156,7 +1257,7 @@
// Tokens
React.createElement(
"div",
- cardProps(onTokensClick, "Tokens details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onTokensClick, t("summary.tokens", "Tokens") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1167,7 +1268,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, BlocksIcon(14)),
- React.createElement(CardTitle, null, "Tokens"),
+ React.createElement(CardTitle, null, t("summary.tokens", "Tokens")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1207,7 +1308,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "2.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Cached"),
+ React.createElement("span", { style: { width: "2.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.cached", "Cached")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
@@ -1226,7 +1327,7 @@
const maxPace = Math.max(nominalPace, trendPace, 1);
return React.createElement(
"div",
- cardProps(onPaceClick, "Pace details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onPaceClick, t("summary.pace", "Pace") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1237,7 +1338,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, MetronomeIcon(14)),
- React.createElement(CardTitle, null, "Pace"),
+ React.createElement(CardTitle, null, t("summary.pace", "Pace")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1253,7 +1354,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "3.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Nominal"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.nominal", "Nominal")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
@@ -1264,7 +1365,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "3.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Trend"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.trend", "Trend")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
@@ -1280,10 +1381,11 @@
);
}
- // src/components/LeaderBoard.js
+ // dashboard/src/components/LeaderBoard.js
function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopTokensClick, onTopPaceClick }) {
+ const t = useCronalyticsI18n();
const totalRuns = jobList.reduce((sum, j) => sum + (j.runs || 0), 0);
- const totalCost = jobList.reduce((sum, j) => sum + (j.total_cost || 0), 0);
+ const totalCost = jobList.reduce((sum, j) => sum + (j.tot_estimated_cost || 0), 0);
const totalTokens = jobList.reduce((sum, j) => sum + (j.total_tokens || 0), 0);
const cardHover = {
onMouseEnter: (e) => {
@@ -1325,7 +1427,7 @@
const label = j ? j.name || j.job_id : "\u2014";
return React.createElement(
"div",
- cardProps(onTopRunsClick, "Top Runs details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onTopRunsClick, t("leaderboard.top_runs", "Top Runs") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1336,7 +1438,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, ZapIcon(14)),
- React.createElement(CardTitle, null, "Top Runs"),
+ React.createElement(CardTitle, null, t("leaderboard.top_runs", "Top Runs")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1353,7 +1455,7 @@
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalRuns > 0 ? Math.round((j.runs || 0) / totalRuns * 100) + "% of total runs" : ""
+ totalRuns > 0 ? Math.round((j.runs || 0) / totalRuns * 100) + "% " + t("leaderboard.of_total_runs", "% of total runs") : ""
)
)
)
@@ -1361,11 +1463,11 @@
})(),
// Top Cost
(() => {
- const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.total_cost || 0) > (a.total_cost || 0) ? b : a, jobList[0]) : null;
+ const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.tot_estimated_cost || 0) > (a.tot_estimated_cost || 0) ? b : a, jobList[0]) : null;
const label = j ? j.name || j.job_id : "\u2014";
return React.createElement(
"div",
- cardProps(onTopCostClick, "Top Cost details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onTopCostClick, t("leaderboard.top_est_cost", "Top Cost") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1376,16 +1478,21 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, BanknoteIcon(14)),
- React.createElement(CardTitle, null, "Top Cost"),
+ React.createElement(CardTitle, null, t("leaderboard.top_est_cost", "Top Cost")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
React.createElement(
CardContent,
null,
- React.createElement("div", {
- style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", lineHeight: 1.15, color: "#f5a623", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }
- }, j ? fmtCost(j.total_cost) : "\u2014"),
+ React.createElement(
+ "div",
+ { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("div", {
+ style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" }
+ }, j ? fmtCost(j.tot_estimated_cost) : "\u2014"),
+ j && React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.95, fontFamily: "var(--theme-font-mono, monospace)", background: "rgba(245,166,35,0.12)", border: "1px solid rgba(245,166,35,0.25)", borderRadius: "0.25rem", padding: "0.05rem 0.4rem" } }, t("summary.estimated", "Estimated"))
+ ),
React.createElement("div", {
style: { fontSize: "0.75rem", fontWeight: 600, fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.2rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
title: label
@@ -1393,7 +1500,7 @@
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalCost > 0 ? Math.round((j.total_cost || 0) / totalCost * 100) + "% of total cost" : ""
+ totalCost > 0 ? Math.round((j.tot_estimated_cost || 0) / totalCost * 100) + "% " + t("leaderboard.of_total_est_cost", "% of total est cost") : ""
)
)
)
@@ -1405,7 +1512,7 @@
const label = j ? j.name || j.job_id : "\u2014";
return React.createElement(
"div",
- cardProps(onTopTokensClick, "Top Tokens details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onTopTokensClick, t("leaderboard.top_tokens", "Top Tokens") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1416,7 +1523,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, BlocksIcon(14)),
- React.createElement(CardTitle, null, "Top Tokens"),
+ React.createElement(CardTitle, null, t("leaderboard.top_tokens", "Top Tokens")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1433,7 +1540,7 @@
React.createElement(
"div",
{ style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalTokens > 0 ? Math.round((j.total_tokens || 0) / totalTokens * 100) + "% of total tokens" : ""
+ totalTokens > 0 ? Math.round((j.total_tokens || 0) / totalTokens * 100) + "% " + t("leaderboard.of_total_tokens", "% of total tokens") : ""
)
)
)
@@ -1450,7 +1557,7 @@
const p = j && j.projections && j.projections.pace != null ? j.projections.pace : null;
return React.createElement(
"div",
- cardProps(onTopPaceClick, "Top Pace details", { minWidth: 0, overflow: "hidden" }),
+ cardProps(onTopPaceClick, t("leaderboard.most_efficient", "Top Pace") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(
Card,
{ style: { flex: 1 } },
@@ -1461,7 +1568,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, MetronomeIcon(14)),
- React.createElement(CardTitle, null, "Top Pace"),
+ React.createElement(CardTitle, null, t("leaderboard.most_efficient", "Top Pace")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -1483,12 +1590,13 @@
);
}
- // src/components/ModelBreakdown.js
+ // dashboard/src/components/ModelBreakdown.js
function ModelBreakdown({ costByModel }) {
+ const t = useCronalyticsI18n();
if (!costByModel || costByModel.length === 0) return null;
const topModels = costByModel.slice(0, 5);
const remaining = costByModel.length - 5;
- const maxCost = topModels[0] && topModels[0].total_cost || 1;
+ const maxCost = topModels[0] && topModels[0].tot_estimated_cost || 1;
return React.createElement(
Card,
{ style: { marginBottom: "1.5rem" } },
@@ -1499,7 +1607,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.5rem" } },
CpuIcon(16),
- React.createElement(CardTitle, null, "Per-Model Breakdown")
+ React.createElement(CardTitle, null, t("model_breakdown.title", "Per-Model Breakdown"))
)
),
React.createElement(
@@ -1529,7 +1637,7 @@
style: { flex: 1, background: "rgba(255,255,255,0.04)", height: "0.4rem", borderRadius: "0.2rem", overflow: "hidden" }
},
React.createElement("div", {
- style: { width: Math.min(100, (m.total_cost || 0) / maxCost * 100) + "%", background: "#f5a623", height: "100%", borderRadius: "0.2rem", transition: "width 0.5s ease" }
+ style: { width: Math.min(100, (m.tot_estimated_cost || 0) / maxCost * 100) + "%", background: "#f5a623", height: "100%", borderRadius: "0.2rem", transition: "width 0.5s ease" }
})
),
React.createElement(
@@ -1537,19 +1645,19 @@
{
style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", flexShrink: 0, whiteSpace: "nowrap", display: "flex", alignItems: "center", gap: "0.35rem", minWidth: 0, flex: "0 0 9rem", justifyContent: "flex-end" }
},
- React.createElement("span", { style: { color: "#f5a623", width: "4.5rem", textAlign: "right", display: "inline-block" } }, fmtCost(m.total_cost)),
+ React.createElement("span", { style: { color: "#f5a623", width: "4.5rem", textAlign: "right", display: "inline-block" } }, fmtCost(m.tot_estimated_cost)),
React.createElement("span", { style: { opacity: 0.45, width: "3.5rem", textAlign: "right", display: "inline-block" } }, "\xB7 " + (m.runs || 0).toLocaleString())
)
)),
remaining > 0 && React.createElement("div", {
style: { textAlign: "center", fontSize: "0.65rem", opacity: 0.35, marginTop: "0.3rem", fontFamily: "var(--theme-font-mono, monospace)" }
- }, "and " + remaining + " more")
+ }, t("model_breakdown.and_more", "and {n} more", { n: remaining }))
)
)
);
}
- // src/components/JobBreakdown.js
+ // dashboard/src/components/JobBreakdown.js
function JobBreakdown({
jobList,
sortedJobs,
@@ -1564,6 +1672,17 @@
onExpandToggle,
onSelectJob
}) {
+ const t = useCronalyticsI18n();
+ const HEADERS = [
+ t("job_breakdown.job", "Job"),
+ t("job_breakdown.runs", "Runs"),
+ t("job_breakdown.avg_time", "Avg Duration"),
+ t("job_breakdown.est_cost", "Est Cost"),
+ t("job_breakdown.avg_est_cost", "Avg Est Cost"),
+ t("job_breakdown.nominal_mo", "Nominal/mo"),
+ t("job_breakdown.trend_mo", "Trend/mo"),
+ t("job_breakdown.pace", "Pace")
+ ];
return React.createElement(
Card,
{ style: { marginBottom: "1.5rem" } },
@@ -1577,7 +1696,7 @@
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.5rem" } },
ClockIcon(16),
- React.createElement(CardTitle, null, "Jobs Breakdown")
+ React.createElement(CardTitle, null, t("job_breakdown.title", "Jobs Breakdown"))
),
React.createElement(
"div",
@@ -1594,8 +1713,8 @@
"span",
{ style: { display: "inline-flex", alignItems: "center", gap: "0.35rem" } },
RefreshCwIcon(14, { style: { animation: "cronalytics-spin 1s linear infinite" } }),
- "Syncing"
- ) : "Sync Now"
+ t("shared.loading", "Syncing")
+ ) : t("shared.sync_now", "Sync Now")
),
syncInfo && syncInfo.lastSync && (() => {
const age = fmtSyncAge(syncInfo.lastSync);
@@ -1617,7 +1736,7 @@
jobList.length === 0 ? React.createElement(
"div",
{ style: { opacity: 0.6, padding: "1rem 0" } },
- syncing ? "Syncing cron sessions..." : syncInfo && syncInfo.lastSync ? "No jobs in " + windowLabel.toLowerCase() + ". Last sync: " + syncInfo.lastSync.split("T").join(" ").slice(0, 19) + " UTC" : "No cron jobs captured. Click Sync Now to backfill from state.db."
+ syncing ? t("shared.loading", "Syncing cron sessions...") : syncInfo && syncInfo.lastSync ? t("job_breakdown.no_jobs_window", "No jobs in {window}. Last sync: {time} UTC", { window: windowLabel.toLowerCase(), time: syncInfo.lastSync.split("T").join(" ").slice(0, 19) }) : t("job_breakdown.no_jobs_sync", "No cron jobs captured. Click Sync Now to backfill from state.db.")
) : React.createElement(
"div",
{ style: { overflow: "auto" } },
@@ -1630,13 +1749,13 @@
React.createElement(
"tr",
{ style: { borderBottom: "1px solid var(--color-border)" } },
- ["Job", "Runs", "Avg Time", "Total Cost", "Avg Cost", "Nominal/mo", "Trend/mo", "Pace"].map((h) => {
+ HEADERS.map((h) => {
const isActive = sortConfig.key === h;
return React.createElement("th", {
key: h,
tabIndex: 0,
role: "button",
- "aria-label": isActive ? "Sorted by " + h + ", " + (sortConfig.direction === "asc" ? "ascending" : "descending") : "Sort by " + h,
+ "aria-label": isActive ? t("job_breakdown.sorted_by", "Sorted by {col}, {dir}", { col: h, dir: sortConfig.direction === "asc" ? t("job_breakdown.ascending", "ascending") : t("job_breakdown.descending", "descending") }) : t("job_breakdown.sort_by", "Sort by {col}", { col: h }),
onClick: () => onSort(h),
onKeyDown: (e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -1645,7 +1764,7 @@
}
},
style: {
- textAlign: h === "Job" ? "left" : "right",
+ textAlign: h === HEADERS[0] ? "left" : "right",
padding: "0.5rem 0.35rem",
cursor: "pointer",
fontFamily: "var(--theme-font-mono, monospace)",
@@ -1653,7 +1772,7 @@
userSelect: "none",
borderBottom: "2px solid var(--color-border)"
},
- title: h === "Pace" ? "Pace = Trend \xF7 Nominal. Under 1.0\xD7 = under budget. Over 2.0\xD7 = over budget." : void 0
+ title: h === t("job_breakdown.pace", "Pace") ? "Pace = Trend \xF7 Nominal. Under 1.0\xD7 = under budget. Over 2.0\xD7 = over budget." : void 0
}, h + (isActive ? sortConfig.direction === "asc" ? " \u2191" : " \u2193" : ""));
})
)
@@ -1685,13 +1804,13 @@
j.job_mode === "no_agent" && React.createElement(Badge, {
size: "xs",
style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 }
- }, "No agent")
+ }, t("job_breakdown.mode_no_agent", "No agent"))
)
),
React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, (j.runs || 0).toLocaleString()),
React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtDuration(j.avg_duration)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.total_cost)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.avg_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.tot_estimated_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.avg_estimated_cost)),
React.createElement(
"td",
{ style: { textAlign: "right", padding: "0.4rem 0.35rem" } },
@@ -1736,7 +1855,7 @@
{
style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.72rem" }
},
- "Tokens: " + fmtCompact(j.total_tokens) + " total (" + fmtCompact(j.total_input_tokens) + " in / " + fmtCompact(j.total_output_tokens) + " out / " + fmtCompact(j.total_cache_read_tokens) + " cached)"
+ t("summary.tokens", "Tokens") + ": " + fmtCompact(j.total_tokens) + " total (" + fmtCompact(j.total_input_tokens) + " " + t("summary.in", "in") + " / " + fmtCompact(j.total_output_tokens) + " " + t("summary.out", "out") + " / " + fmtCompact(j.total_cache_read_tokens) + " " + t("summary.cached", "cached") + ")"
),
React.createElement(
"div",
@@ -1755,13 +1874,13 @@
{
style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", opacity: 0.7, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "flex", alignItems: "center", gap: "0.5rem" }
},
- j.projections && j.projections.schedule_display ? j.projections.schedule_display : "No schedule",
- " Last: ",
+ j.projections && j.projections.schedule_display ? j.projections.schedule_display : t("job_breakdown.no_schedule", "No schedule"),
+ " " + t("job_breakdown.last", "Last") + ": ",
fmtTime(j.last_run),
- j.last_model ? " using " + j.last_model : "",
- " Next: ",
+ j.last_model ? " " + t("job_breakdown.using", "using") + " " + j.last_model : "",
+ " " + t("job_breakdown.next", "Next") + ": ",
j.projections && j.projections.next_run_at ? fmtRel(j.projections.next_run_at) : "\u2014",
- j.job_mode === "no_agent" && React.createElement("span", { style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.5, marginLeft: "0.25rem" } }, "[No agent]")
+ j.job_mode === "no_agent" && React.createElement("span", { style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.5, marginLeft: "0.25rem" } }, "[" + t("job_breakdown.mode_no_agent", "No agent") + "]")
),
React.createElement("button", {
type: "button",
@@ -1787,7 +1906,7 @@
onMouseLeave: (e) => {
e.currentTarget.style.background = "rgba(255,255,255,0.08)";
}
- }, "See Runs")
+ }, t("job_breakdown.see_runs", "See Runs"))
)
)
)
@@ -1800,8 +1919,9 @@
);
}
- // src/components/CronalyticsTab.js
+ // dashboard/src/components/CronalyticsTab.js
function CronalyticsTab() {
+ const t = useCronalyticsI18n();
const [days, setDaysRaw] = useState(() => {
try {
const saved = localStorage.getItem("cronalytics:days");
@@ -1820,10 +1940,16 @@
const [outcome, setOutcomeRaw] = useState(() => {
try {
const saved = localStorage.getItem("cronalytics:outcome");
- if (saved) return saved;
+ if (saved) {
+ if (saved === "both") {
+ localStorage.setItem("cronalytics:outcome", "all");
+ return "all";
+ }
+ return saved;
+ }
} catch {
}
- return "both";
+ return "all";
});
const setOutcome = (v) => {
try {
@@ -1904,7 +2030,7 @@
setSyncing(false);
if (syncResult && syncResult.result) {
const { inserted, elapsed_ms } = syncResult.result;
- setSyncToast({ msg: "\u2713 Synced " + inserted + " runs \xB7 " + (elapsed_ms / 1e3).toFixed(1) + "s" });
+ setSyncToast({ msg: "\u2713 " + t("shared.synced_n_runs", "Synced {n} runs") + " \xB7 " + (elapsed_ms / 1e3).toFixed(1) + "s", n: inserted });
setTimeout(() => setSyncToast(null), 5e3);
}
summary.refetch();
@@ -1919,31 +2045,31 @@
return React.createElement(
"div",
{ style: { padding: "0 0.25rem 1rem 0", color: "var(--color-destructive)" } },
- "Error: " + (summary.error || jobs.error)
+ t("job_detail.error_prefix", "Error: ") + (summary.error || jobs.error)
);
}
const s = summary.data || {};
const jobList = jobs.data && jobs.data.jobs ? jobs.data.jobs : [];
- const windowLabel = days === 0 ? "All time" : "Last " + days + " days";
- const costPct = s.previous_period && s.previous_period.cost != null && s.previous_period.cost !== 0 ? (s.total_estimated_cost - s.previous_period.cost) / s.previous_period.cost * 100 : null;
+ const windowLabel = days === 0 ? t("summary.all_time", "All time") : t("summary.last_n_days", "Last {n} days", { n: days });
+ const costPct = s.previous_period && s.previous_period.estimated_cost != null && s.previous_period.estimated_cost !== 0 ? (s.tot_estimated_cost - s.previous_period.estimated_cost) / s.previous_period.estimated_cost * 100 : null;
const runPct = s.previous_period && s.previous_period.runs != null && s.previous_period.runs !== 0 ? (s.total_runs - s.previous_period.runs) / s.previous_period.runs * 100 : null;
const getSortValue = (j, key) => {
switch (key) {
- case "Job":
+ case t("job_breakdown.job", "Job"):
return j.name || j.job_id;
- case "Runs":
+ case t("job_breakdown.runs", "Runs"):
return j.runs || 0;
- case "Avg Time":
+ case t("job_breakdown.avg_time", "Avg Duration"):
return j.avg_duration || 0;
- case "Total Cost":
- return j.total_cost || 0;
- case "Avg Cost":
- return j.avg_cost || 0;
- case "Nominal/mo":
+ case t("job_breakdown.est_cost", "Est Cost"):
+ return j.tot_estimated_cost || 0;
+ case t("job_breakdown.avg_est_cost", "Avg Est Cost"):
+ return j.avg_estimated_cost || 0;
+ case t("job_breakdown.nominal_mo", "Nominal/mo"):
return j.projections && j.projections.projected_cost_30d != null ? j.projections.projected_cost_30d : -Infinity;
- case "Trend/mo":
+ case t("job_breakdown.trend_mo", "Trend/mo"):
return j.projections && j.projections.trend_projected_cost_30d != null ? j.projections.trend_projected_cost_30d : -Infinity;
- case "Pace":
+ case t("job_breakdown.pace", "Pace"):
return j.projections && j.projections.pace != null ? j.projections.pace : -Infinity;
default:
return 0;
@@ -1994,16 +2120,14 @@
React.createElement(
"div",
{ style: { display: "flex", flexWrap: "nowrap", gap: "0.75rem", alignItems: "center" } },
- React.createElement(OutcomeToggle, { selected: outcome, onChange: setOutcome, label: "Outcomes" }),
- React.createElement(ModeToggle, { selected: mode, onChange: setMode, label: "Mode" })
+ React.createElement(OutcomeToggle, { selected: outcome, onChange: setOutcome, label: t("outcome_toggle.label", "Outcomes") }),
+ React.createElement(ModeToggle, { selected: mode, onChange: setMode, label: t("mode_toggle.label", "Mode") })
),
// Spacer pushes DaySelector + Refresh to the right edge.
- // Using a flex child instead of marginLeft: auto so that when items wrap,
- // each wrapped line starts from the left and uses full toolbar width.
React.createElement("div", { style: { flex: "1 1 0%", minWidth: "0.25rem" } }),
// DaySelector returns [label, presets, custom] — flattened as direct flex children
// of the toolbar so presets, custom input, and Refresh wrap progressively.
- React.createElement(DaySelector, { selected: days, onChange: setDays, label: "Days" }),
+ React.createElement(DaySelector, { selected: days, onChange: setDays, label: null }),
// Refresh — its own flex item so it breaks away first at 110%.
React.createElement(
Button,
@@ -2015,14 +2139,11 @@
onClick: () => {
summary.refetch();
jobs.refetch();
- }
+ },
+ title: "Refresh",
+ style: { minHeight: "28px", display: "flex", alignItems: "center", justifyContent: "center" }
},
- summary.loading || jobs.loading ? "\u2026" : React.createElement(
- "span",
- { style: { display: "flex", alignItems: "center", gap: "0.25rem" } },
- RefreshCwIcon(14),
- "Refresh"
- )
+ summary.loading || jobs.loading ? RefreshCwIcon(14, { style: { animation: "cronalytics-spin 1s linear infinite" } }) : RefreshCwIcon(14)
)
),
// Job Detail Modal
@@ -2036,7 +2157,7 @@
jobName: (jobList.find((j) => j.job_id === selectedJobId) || {}).name,
days,
outcome,
- sortKey: { "Job": "run_time", "Runs": "run_time", "Avg Time": "duration_seconds", "Total Cost": "estimated_cost_usd", "Avg Cost": "estimated_cost_usd", "Nominal/mo": "run_time", "Trend/mo": "run_time", "Pace": "run_time" }[sortConfig.key] || "run_time",
+ sortKey: { [t("job_breakdown.job", "Job")]: "run_time", [t("job_breakdown.runs", "Runs")]: "run_time", [t("job_breakdown.avg_time", "Avg Duration")]: "duration_seconds", [t("job_breakdown.est_cost", "Est Cost")]: "estimated_cost", [t("job_breakdown.avg_est_cost", "Avg Est Cost")]: "estimated_cost", [t("job_breakdown.nominal_mo", "Nominal/mo")]: "run_time", [t("job_breakdown.trend_mo", "Trend/mo")]: "run_time", [t("job_breakdown.pace", "Pace")]: "run_time" }[sortConfig.key] || "run_time",
sortDir: sortConfig.direction || "desc"
})),
React.createElement(SummaryBoard, {
@@ -2070,7 +2191,7 @@
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: paceColor(s.pace) } },
s.pace != null ? s.pace.toFixed(2) + "\xD7" : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Pace")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.pace", "Pace"))
),
React.createElement(
"div",
@@ -2081,7 +2202,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, "Nominal"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, t("summary.nominal", "Nominal")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
@@ -2092,7 +2213,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, "Trend"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, t("summary.trend", "Trend")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
@@ -2102,48 +2223,7 @@
)
)
),
- React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem", textTransform: "none" } },
- "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019"
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "How it\u2019s calculated"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6, textTransform: "none" } },
- React.createElement("div", null, "Nominal = scheduled runs \xD7 average cost per run"),
- React.createElement("div", null, "Trend = actual runs \xD7 average cost per run"),
- React.createElement("div", null, "Pace = Trend / Nominal"),
- React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6, textTransform: "none" } }, "All scaled to a 30\u2011day month using the selected window.")
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Color guide"),
- React.createElement(
- "div",
- { style: { display: "flex", flexDirection: "column", gap: "0.35rem", fontSize: "0.78rem", marginBottom: "0.75rem", textTransform: "none" } },
- React.createElement(
- "div",
- { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#4ade80" } }),
- React.createElement("span", null, "Green (< 1.0\xD7) \u2014 Under budget. Spending less than scheduled.")
- ),
- React.createElement(
- "div",
- { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "var(--foreground)" } }),
- React.createElement("span", null, "Neutral (1.0\u20132.0\xD7) \u2014 On track. Slight variance within normal range.")
- ),
- React.createElement(
- "div",
- { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#ef4444" } }),
- React.createElement("span", null, "Red (\u2265 2.0\xD7) \u2014 Over budget. Actual spend is double (or more) the nominal rate.")
- )
- )
- )
+ React.createElement(PaceExplainer, { s, windowLabel, t })
)
),
// ── Runs Modal ─────────────────────────────────────────────────────
@@ -2161,7 +2241,7 @@
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)" } },
(s.total_runs || 0).toLocaleString()
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Job Runs")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.job_runs", "Job Runs"))
),
runPct != null && React.createElement(
"div",
@@ -2169,34 +2249,10 @@
React.createElement(
"div",
{ style: { fontSize: "0.82rem", color: runPct > 0 ? "#ef4444" : "#4ade80" } },
- (runPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(runPct).toFixed(0) + "% vs prior " + (days === 0 ? "period" : days + "d")
+ (runPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(runPct).toFixed(0) + "% " + t("summary.vs_prior", "vs prior") + " " + (days === 0 ? t("summary.period", "period") : days + "d")
)
),
- React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Trend calculation"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Trend % = ((current runs \u2212 prior runs) / prior runs) \xD7 100"),
- React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6 } }, "Positive = more runs than the prior window. Negative = fewer runs.")
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Window context"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
- "Showing ",
- React.createElement("strong", null, windowLabel),
- ". The prior comparison window is the same duration shifted back in time."
- )
- )
+ React.createElement(RunsExplainer, { windowLabel, t })
)
),
// ── Cost Modal ─────────────────────────────────────────────────────
@@ -2212,15 +2268,15 @@
React.createElement(
"span",
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" } },
- fmtCost(s.total_estimated_cost)
+ fmtCost(s.tot_estimated_cost)
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Estimated Cost")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.estimated", "Estimated") + " " + t("summary.cost", "Cost"))
),
- s.total_actual_cost != null && React.createElement(
+ s.tot_actual_cost != null && React.createElement(
"div",
{ style: { marginBottom: "0.75rem", fontSize: "0.8rem", opacity: 0.85 } },
- "Actual: ",
- React.createElement("span", { style: { fontWeight: 700 } }, fmtCost(s.total_actual_cost))
+ t("summary.actual", "Actual") + ": ",
+ React.createElement("span", { style: { fontWeight: 700 } }, fmtCost(s.tot_actual_cost))
),
costPct != null && React.createElement(
"div",
@@ -2228,33 +2284,10 @@
React.createElement(
"div",
{ style: { fontSize: "0.82rem", color: costPct > 0 ? "#ef4444" : "#4ade80" } },
- (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "% vs prior " + (days === 0 ? "period" : days + "d")
+ (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "% " + t("summary.vs_prior", "vs prior") + " " + (days === 0 ? t("summary.period", "period") : days + "d")
)
),
- React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Trend calculation"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Trend % = ((current cost \u2212 prior cost) / prior cost) \xD7 100")
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Window context"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
- "Showing ",
- React.createElement("strong", null, windowLabel),
- ". The prior comparison window is the same duration shifted back in time."
- )
- )
+ React.createElement(CostExplainer, { windowLabel, t })
)
),
// ── Tokens Modal ───────────────────────────────────────────────────
@@ -2272,7 +2305,7 @@
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#5b8def" } },
fmtCompact(s.total_tokens)
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Tokens")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.tokens", "Tokens"))
),
React.createElement(
"div",
@@ -2280,7 +2313,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "In"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.in", "In")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
@@ -2291,7 +2324,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "Out"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.out", "Out")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
@@ -2302,7 +2335,7 @@
React.createElement(
"div",
{ style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "Cached"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.cached", "Cached")),
React.createElement(
"div",
{ style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
@@ -2311,24 +2344,7 @@
React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem" } }, fmtCompact(s.total_cache_read_tokens))
)
),
- React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement(
- "p",
- { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper)."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Breakdown"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Input: " + fmtCompact(s.total_input_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_input_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
- React.createElement("div", null, "Output: " + fmtCompact(s.total_output_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_output_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
- React.createElement("div", null, "Cached: " + fmtCompact(s.total_cache_read_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_cache_read_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)")
- )
- )
+ React.createElement(TokensExplainer, { s, t })
)
),
// ── Top Runs Modal ─────────────────────────────────────────────────
@@ -2353,22 +2369,9 @@
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)" } },
j ? (j.runs || 0).toLocaleString() : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "runs")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.job_runs", "Job Runs"))
),
- j && React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + (j.schedule && j.schedule.display || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Avg duration: " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014")),
- React.createElement("div", null, "Tokens: " + (j.total_tokens != null ? fmtCompact(j.total_tokens) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -2381,7 +2384,7 @@
"div",
{ style: { padding: "1.5rem", fontFamily: "var(--theme-font-mono, monospace)", textTransform: "none" } },
(() => {
- const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.total_cost || 0) > (a.total_cost || 0) ? b : a, jobList[0]) : null;
+ const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.tot_estimated_cost || 0) > (a.tot_estimated_cost || 0) ? b : a, jobList[0]) : null;
const label = j ? j.name || j.job_id : "\u2014";
return React.createElement(
"div",
@@ -2393,24 +2396,11 @@
React.createElement(
"span",
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" } },
- j ? fmtCost(j.total_cost) : "\u2014"
+ j ? fmtCost(j.tot_estimated_cost) : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "total cost")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.estimated", "Estimated") + " " + t("summary.cost", "Cost"))
),
- j && React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + (j.schedule && j.schedule.display || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Avg duration: " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString() + " \xB7 Avg: " + (j.avg_cost != null ? fmtCost(j.avg_cost) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -2437,21 +2427,9 @@
{ style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#5b8def" } },
j ? fmtCompact(j.total_tokens) : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "tokens")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.tokens", "Tokens"))
),
- j && React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + (j.schedule && j.schedule.display || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString())
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -2480,62 +2458,35 @@
{ style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } },
React.createElement(
"span",
- { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: paceColor(p) } },
+ { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: p != null ? paceColor(p) : null } },
p != null ? p.toFixed(2) + "\xD7" : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "pace")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.pace", "Pace"))
),
- React.createElement(
- "div",
- { style: { marginBottom: "1rem" } },
- React.createElement(
- "div",
- { style: { display: "flex", flexDirection: "column", gap: "0.2rem" } },
- React.createElement(
- "div",
- { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "5rem", fontSize: "0.8rem" } }, "Nominal/mo"),
- React.createElement(
- "div",
- { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
- React.createElement("div", { style: { width: Math.min(100, (j && j.projections && j.projections.projected_cost_30d || 1) / Math.max(j && j.projections && j.projections.projected_cost_30d || 1, j && j.projections && j.projections.trend_projected_cost_30d || 1, 1) * 100) + "%", background: "#4ade80", height: "100%", opacity: 0.8 } })
- ),
- React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem", color: "#4ade80" } }, fmtCost(j && j.projections ? j.projections.projected_cost_30d : null) + "/mo")
- ),
- React.createElement(
- "div",
- { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "5rem", fontSize: "0.8rem" } }, "Trend/mo"),
- React.createElement(
- "div",
- { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
- React.createElement("div", { style: { width: Math.min(100, (j && j.projections && j.projections.trend_projected_cost_30d || 1) / Math.max(j && j.projections && j.projections.projected_cost_30d || 1, j && j.projections && j.projections.trend_projected_cost_30d || 1, 1) * 100) + "%", background: "#ef4444", height: "100%", opacity: 0.8 } })
- ),
- React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem", color: "#ef4444" } }, fmtCost(j && j.projections ? j.projections.trend_projected_cost_30d : null) + "/mo")
- )
- )
- ),
- j && React.createElement(
- "div",
- { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement(
- "div",
- { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + (j.schedule && j.schedule.display || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString() + " \xB7 Avg cost: " + (j.avg_cost != null ? fmtCost(j.avg_cost) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
),
+ // ModelBreakdown — full width
React.createElement(ModelBreakdown, { costByModel: s.cost_by_model }),
- mode === "all" && s.script_jobs_in_window > 0 && React.createElement("div", {
- style: { fontSize: "0.65rem", opacity: 0.45, fontFamily: "var(--theme-font-mono, monospace)", marginBottom: "0.5rem", paddingLeft: "0.25rem" }
- }, s.script_jobs_in_window + " no-agent job" + (s.script_jobs_in_window === 1 ? "" : "s") + " at $0.00 included. Filter to isolate agent costs."),
+ // Toast
+ syncToast && React.createElement("div", {
+ style: {
+ position: "fixed",
+ bottom: 24,
+ right: 24,
+ zIndex: 2e3,
+ fontSize: "0.72rem",
+ fontFamily: "var(--theme-font-mono, monospace)",
+ padding: "0.5rem 0.75rem",
+ borderRadius: "0.35rem",
+ background: "rgba(34,197,94,0.12)",
+ border: "1px solid rgba(34,197,94,0.3)",
+ color: "#4ade80",
+ animation: "cronalytics-fadein 0.3s ease"
+ }
+ }, syncToast.msg),
React.createElement(JobBreakdown, {
jobList,
sortedJobs,
@@ -2546,33 +2497,882 @@
days,
windowLabel,
onSync,
- onSort: (h) => setSortConfig((prev) => ({ key: h, direction: prev.key === h && prev.direction === "asc" ? "desc" : "asc" })),
+ onSort: (key) => {
+ if (sortConfig.key === key) {
+ setSortConfig({ key, direction: sortConfig.direction === "asc" ? "desc" : "asc" });
+ } else {
+ setSortConfig({ key, direction: "asc" });
+ }
+ },
onExpandToggle: (id) => setExpandedId(expandedId === id ? null : id),
onSelectJob: setSelectedJobId
- }),
- // Sync toast
- syncToast && React.createElement("div", {
- style: {
- position: "fixed",
- bottom: "1.5rem",
- left: "50%",
- transform: "translateX(-50%)",
- background: "var(--background)",
- color: "var(--foreground-base, var(--foreground))",
- border: "1px solid var(--foreground-base, var(--foreground))",
- borderRadius: "0.5rem",
- padding: "0.6rem 1.25rem",
- fontSize: "0.85rem",
- fontFamily: "var(--theme-font-mono, monospace)",
- zIndex: 1e4,
- boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
- whiteSpace: "nowrap"
- }
- }, syncToast.msg)
+ })
+ );
+ }
+ function PaceExplainer({ s, windowLabel, t }) {
+ return React.createElement(
+ "div",
+ { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem", textTransform: "none" } },
+ t("pace.what_this_means", "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.how_its_calculated", "How it's calculated")),
+ React.createElement(
+ "div",
+ { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6, textTransform: "none" } },
+ React.createElement("div", null, t("pace.nominal_formula", "Nominal = scheduled runs \xD7 average cost per run")),
+ React.createElement("div", null, t("pace.trend_formula", "Trend = actual runs \xD7 average cost per run")),
+ React.createElement("div", null, t("pace.pace_formula", "Pace = Trend / Nominal")),
+ React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6, textTransform: "none" } }, t("shared.all_scaled_30d", "All scaled to a 30\u2011day month using the selected window."))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.color_guide", "Color guide")),
+ React.createElement(
+ "div",
+ { style: { display: "flex", flexDirection: "column", gap: "0.35rem", fontSize: "0.78rem", marginBottom: "0.75rem", textTransform: "none" } },
+ React.createElement(
+ "div",
+ { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#4ade80" } }),
+ React.createElement("span", null, t("shared.green_under_budget", "Green (< 1.0\xD7) \u2014 Under budget. Spending less than scheduled."))
+ ),
+ React.createElement(
+ "div",
+ { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "var(--foreground)" } }),
+ React.createElement("span", null, t("shared.neutral_budget", "Neutral (1.0\u20132.0\xD7) \u2014 On track. Slight variance within normal range."))
+ ),
+ React.createElement(
+ "div",
+ { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#ef4444" } }),
+ React.createElement("span", null, t("shared.red_over_budget", "Red (\u2265 2.0\xD7) \u2014 Over budget. Actual spend is double (or more) the nominal rate."))
+ )
+ )
+ );
+ }
+ function RunsExplainer({ windowLabel, t }) {
+ return React.createElement(
+ "div",
+ { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("runs.what_this_means", "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries.")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.trend_calculation", "Trend calculation")),
+ React.createElement(
+ "div",
+ { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("runs.trend_formula", "Trend % = ((current runs \u2212 prior runs) / prior runs) \xD7 100")),
+ React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6 } }, t("runs.trend_note", "Positive = more runs than the prior window. Negative = fewer runs."))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.window_context", "Window context")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
+ t("shared.showing_window", "Showing "),
+ React.createElement("strong", null, windowLabel),
+ ". " + t("shared.prior_window_note", "The prior comparison window is the same duration shifted back in time.")
+ )
+ );
+ }
+ function CostExplainer({ windowLabel, t }) {
+ return React.createElement(
+ "div",
+ { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("cost.what_this_means", "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity.")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.trend_calculation", "Trend calculation")),
+ React.createElement(
+ "div",
+ { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("cost.trend_formula", "Trend % = ((current cost \u2212 prior cost) / prior cost) \xD7 100"))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.window_context", "Window context")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
+ t("shared.showing_window", "Showing "),
+ React.createElement("strong", null, windowLabel),
+ ". " + t("shared.prior_window_note", "The prior comparison window is the same duration shifted back in time.")
+ )
);
}
+ function TokensExplainer({ s, t }) {
+ return React.createElement(
+ "div",
+ { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement(
+ "p",
+ { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("tokens.what_this_means", "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper).")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.breakdown", "Breakdown")),
+ React.createElement(
+ "div",
+ { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("summary.in", "Input") + ": " + fmtCompact(s.total_input_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_input_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
+ React.createElement("div", null, t("summary.out", "Output") + ": " + fmtCompact(s.total_output_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_output_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
+ React.createElement("div", null, t("summary.cached", "Cached") + ": " + fmtCompact(s.total_cache_read_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_cache_read_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)")
+ )
+ );
+ }
+ function JobDetailsBlock({ j, t }) {
+ return React.createElement(
+ "div",
+ { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.job_details", "Job details")),
+ React.createElement(
+ "div",
+ { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("job_breakdown.schedule", "Schedule") + ": " + (j.schedule && j.schedule.display || "\u2014")),
+ React.createElement("div", null, t("job_breakdown.last_run", "Last run") + ": " + fmtTime(j.last_run)),
+ React.createElement("div", null, t("model_breakdown.model", "Model") + ": " + (j.last_model || "\u2014")),
+ React.createElement("div", null, t("job_breakdown.avg_time", "Avg Duration") + ": " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014"))
+ )
+ );
+ }
+
+ // dashboard/src/i18n/en.js
+ registerCatalog("en", {
+ // HeroBanner — the greeting
+ hero: {
+ title: "CRONALYTICS",
+ tagline: "Observe. Measure. Optimize.",
+ pronunciation: "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/",
+ noun: "(noun)",
+ definition_1: "1. Cron analytics and observability.",
+ definition_2: "2. The dashboard for agentic automations in Hermes.",
+ expand_tooltip: "Expand hero banner",
+ collapse_tooltip: "Collapse hero banner"
+ },
+ // SummaryBoard — headline stats
+ summary: {
+ job_runs: "Job Runs",
+ cost: "Cost",
+ wasted: "Wasted",
+ tokens: "Tokens",
+ cached: "Cached",
+ pace: "Pace",
+ trend: "Trend",
+ estimated: "Estimated",
+ actual: "Actual",
+ all_time: "All time",
+ last_n_days: "Last {n} days",
+ vs_prior: "vs prior",
+ period: "period",
+ nominal: "Nominal",
+ in: "In",
+ out: "Out",
+ no_schedule: "No schedule"
+ },
+ // LeaderBoard — top performers
+ leaderboard: {
+ title: "Leaderboard",
+ top_est_cost: "Top Cost",
+ top_runs: "Top Runs",
+ top_tokens: "Top Tokens",
+ top_duration: "Top Time",
+ most_efficient: "Top Pace",
+ of_total_est_cost: "% of total est cost",
+ of_total_runs: "% of total runs",
+ of_total_tokens: "% of total tokens"
+ },
+ // JobBreakdown — per-job table
+ job_breakdown: {
+ title: "Jobs Breakdown",
+ job: "Job",
+ runs: "Runs",
+ avg_time: "Avg Duration",
+ est_cost: "Est Cost",
+ avg_est_cost: "Avg Est Cost",
+ nominal_mo: "Nominal/mo",
+ trend_mo: "Trend/mo",
+ pace: "Pace",
+ mode_agent: "Agent",
+ mode_no_agent: "No agent",
+ no_schedule: "No schedule",
+ last: "Last",
+ using: "using",
+ next: "Next",
+ see_runs: "See Runs",
+ schedule: "Schedule",
+ last_run: "Last run",
+ no_jobs_window: "No jobs in {window}. Last sync: {time} UTC",
+ no_jobs_sync: "No cron jobs captured. Click Sync Now to backfill from state.db.",
+ sorted_by: "Sorted by {col}, {dir}",
+ sort_by: "Sort by {col}",
+ ascending: "ascending",
+ descending: "descending"
+ },
+ // JobDetailView — individual run history
+ job_detail: {
+ title_runs: "Runs",
+ mode: "Mode",
+ mode_agent: "Agent",
+ duration: "Duration",
+ est_cost: "Est Cost",
+ loading: "Loading runs...",
+ error_prefix: "Error: ",
+ for_full_history: " for full history.",
+ no_runs: "No runs found.",
+ showing: "Showing ",
+ of: " of ",
+ runs_plural: "runs",
+ use_cli: "Use ",
+ run: "run"
+ },
+ // ModelBreakdown — per-model stats
+ model_breakdown: {
+ title: "Per-Model Breakdown",
+ model: "Model",
+ runs: "Runs",
+ est_cost: "Est Cost",
+ and_more: "and {n} more"
+ },
+ // SparkLine — daily trends
+ sparkline: {
+ daily_cost: "Daily Est Cost",
+ daily_runs: "Daily Runs",
+ cost_bar: "\u2014 cost (bar) \xB7 ",
+ tokens_line: "\u2014 tokens",
+ duration_line: "- - duration"
+ },
+ // DaySelector — time window picker
+ day_selector: {
+ label: "Days",
+ apply_custom: "Apply custom days",
+ go: "Go"
+ },
+ // ModeToggle — agent/no_agent/all filter
+ mode_toggle: {
+ label: "Mode",
+ all: "All",
+ agent: "Agent",
+ no_agent: "No Agent"
+ },
+ // OutcomeToggle — success/failure/all filter
+ outcome_toggle: {
+ label: "Outcomes",
+ all: "All",
+ success: "Success",
+ failure: "Failure"
+ },
+ // ErrorBoundary — crash handler
+ error: {
+ title: "Cronalytics Error",
+ message: "Something went wrong. Please refresh or contact support."
+ },
+ // Modal — popup dialog
+ modal: {
+ close: "Close"
+ },
+ // Pace modal explainer
+ pace: {
+ what_this_means: "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019",
+ nominal_formula: "Nominal = scheduled runs \xD7 average cost per run",
+ trend_formula: "Trend = actual runs \xD7 average cost per run",
+ pace_formula: "Pace = Trend / Nominal"
+ },
+ // Runs modal explainer
+ runs: {
+ what_this_means: "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries.",
+ trend_formula: "Trend % = ((current runs \u2212 prior runs) / prior runs) \xD7 100",
+ trend_note: "Positive = more runs than the prior window. Negative = fewer runs."
+ },
+ // Cost modal explainer
+ cost: {
+ what_this_means: "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity.",
+ trend_formula: "Trend % = ((current cost \u2212 prior cost) / prior cost) \xD7 100"
+ },
+ // Tokens modal explainer
+ tokens: {
+ what_this_means: "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper)."
+ },
+ // Shared / generic
+ shared: {
+ loading: "Loading\u2026",
+ retry: "Retry",
+ show: "Show",
+ hide: "Hide",
+ refresh: "Refresh",
+ sync_now: "Sync Now",
+ synced_n_runs: "Synced {n} runs",
+ what_this_means: "What this means",
+ how_its_calculated: "How it's calculated",
+ trend_calculation: "Trend calculation",
+ window_context: "Window context",
+ showing_window: "Showing ",
+ prior_window_note: "The prior comparison window is the same duration shifted back in time.",
+ job_details: "Job details",
+ color_guide: "Color guide",
+ neutral_budget: "Neutral (1.0\u20132.0\xD7) \u2014 On track. Slight variance within normal range.",
+ green_under_budget: "Green (< 1.0\xD7) \u2014 Under budget. Spending less than scheduled.",
+ red_over_budget: "Red (> 2.0\xD7) \u2014 Over budget. Spending more than scheduled.",
+ all_scaled_30d: "All scaled to a 30\u2011day month using the selected window.",
+ breakdown: "Breakdown"
+ }
+ });
+
+ // dashboard/src/i18n/es.js
+ registerCatalog("es", {
+ // cost
+ cost: {
+ trend_formula: "Tendencia % = ((costo presente \u2212 costo anterior) / costo anterior) \xD7 100",
+ what_this_means: "El costo estimado se calcula a partir del uso de tokens y el precio del modelo. El costo real puede diferir ligeramente seg\xFAn la granularidad de facturaci\xF3n del proveedor."
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "Aplicar d\xEDas personalizados",
+ go: "Ir",
+ label: "D\xEDas"
+ },
+ // error
+ error: {
+ message: "Algo sali\xF3 mal. Por favor recarga o contacta soporte.",
+ title: "Error de Cronalytics"
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "Contraer banner principal",
+ definition_1: "1. An\xE1lisis y observabilidad de cron jobs.",
+ definition_2: "2. El panel de control para automatizaciones agentivas en Hermes.",
+ expand_tooltip: "Expandir banner principal",
+ noun: "(sustantivo)",
+ pronunciation: "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/",
+ tagline: "Observar. Medir. Optimizar.",
+ title: "CRONALYTICS"
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "ascendente",
+ avg_est_cost: "Costo est. prom.",
+ avg_time: "Dur. promedio",
+ descending: "descendente",
+ est_cost: "Costo Est.",
+ job: "Trabajo",
+ last: "\xDAltimo",
+ last_run: "\xDAltima ejecuci\xF3n",
+ mode_agent: "Agente",
+ mode_no_agent: "Sin agente",
+ next: "Siguiente",
+ no_jobs_sync: "No se capturaron cron jobs. Haz clic en Sincronizar ahora para rellenar desde state.db.",
+ no_jobs_window: "Sin trabajos en {window}. \xDAltima sincronizaci\xF3n: {time} UTC",
+ no_schedule: "Sin prog.",
+ nominal_mo: "Nominal/mes",
+ pace: "Ritmo",
+ runs: "Ejec.",
+ schedule: "Programaci\xF3n",
+ see_runs: "Ver ejecuciones",
+ sort_by: "Ordenar por {col}",
+ sorted_by: "Ordenado por {col}, {dir}",
+ title: "Desglose de trabajos",
+ trend_mo: "Tendencia/mes",
+ using: "usando"
+ },
+ // job_detail
+ job_detail: {
+ duration: "Duraci\xF3n",
+ error_prefix: "Error: ",
+ est_cost: "Costo Est.",
+ for_full_history: " para historial completo.",
+ loading: "Cargando ejecuciones...",
+ mode: "Modo",
+ mode_agent: "Agente",
+ no_runs: "No se encontraron ejecuciones.",
+ of: " de ",
+ result: "Resultado",
+ run: "ejecuci\xF3n",
+ runs_plural: "ejecuciones",
+ showing: "Mostrando ",
+ time: "Fecha",
+ title_runs: "Ejecuciones",
+ use_cli: "Usar "
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "Mejor ritmo",
+ of_total_est_cost: "% del costo total est.",
+ of_total_runs: "% del total de ejec.",
+ of_total_tokens: "% del total de tokens",
+ title: "Tabla de l\xEDderes",
+ top_duration: "Mayor duraci\xF3n",
+ top_est_cost: "Mayor Costo",
+ top_runs: "M\xE1s ejecuciones",
+ top_tokens: "M\xE1s tokens"
+ },
+ // modal
+ modal: {
+ close: "Cerrar"
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "Agente",
+ all: "Todos",
+ label: "Modo",
+ no_agent: "Sin agente"
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "y {n} m\xE1s",
+ est_cost: "Costo Est.",
+ model: "Modelo",
+ runs: "Ejec.",
+ title: "Desglose por modelo"
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "Todos",
+ failure: "Fallo",
+ label: "Resultados",
+ success: "\xC9xito"
+ },
+ // pace
+ pace: {
+ nominal_formula: "Nominal = ejecuciones programadas \xD7 costo promedio por ejecuci\xF3n",
+ pace_formula: "Ritmo = Tendencia / Nominal",
+ trend_formula: "Tendencia = ejecuciones reales \xD7 costo promedio por ejecuci\xF3n",
+ what_this_means: "El ritmo compara tu tendencia de gasto real contra el presupuesto que configuraste en tus cron jobs. Responde: \u2018A este ritmo, \xBFestoy por encima o por debajo del presupuesto?\u2019"
+ },
+ // runs
+ runs: {
+ trend_formula: "Tendencia % = ((ejec. actuales \u2212 ejec. anteriores) / ejec. anteriores) \xD7 100",
+ trend_note: "Positivo = m\xE1s ejecuciones que la ventana anterior. Negativo = menos ejecuciones.",
+ what_this_means: "N\xFAmero total de ejecuciones de cron jobs registradas en la ventana seleccionada. Cada ejecuci\xF3n activa tu tarea programada, ya sea exitosa, fallida o reintentada."
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "Todo escalado a un mes de 30 d\xEDas usando la ventana seleccionada.",
+ breakdown: "Desglose",
+ color_guide: "Gu\xEDa de colores",
+ green_under_budget: "Verde (< 1.0\xD7) \u2014 Por debajo del presupuesto. Gasto menor al programado.",
+ hide: "Ocultar",
+ how_its_calculated: "C\xF3mo se calcula",
+ job_details: "Detalles del trabajo",
+ loading: "Cargando\u2026",
+ neutral_budget: "Neutral (1.0\u20132.0\xD7) \u2014 En camino. Variaci\xF3n leve dentro del rango normal.",
+ prior_window_note: "La ventana de comparaci\xF3n anterior tiene la misma duraci\xF3n desplazada en el tiempo.",
+ red_over_budget: "Rojo (> 2.0\xD7) \u2014 Sobre presupuesto. Gasto mayor al programado.",
+ refresh: "Actualizar",
+ retry: "Reintentar",
+ show: "Mostrar",
+ showing_window: "Mostrando ",
+ sync_now: "Sincronizar ahora",
+ synced_n_runs: "Sincronizadas {n} ejecuciones",
+ trend_calculation: "C\xE1lculo de tendencia",
+ what_this_means: "Qu\xE9 significa esto",
+ window_context: "Contexto de ventana"
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "\u2014 costo (barra) \xB7 ",
+ daily_cost: "Costo Est. Diario",
+ daily_runs: "Ejecuciones diarias",
+ duration_line: "- - duraci\xF3n",
+ tokens_line: "\u2014 tokens"
+ },
+ // summary
+ summary: {
+ actual: "Real",
+ all_time: "Todo el tiempo",
+ cached: "En cach\xE9",
+ cost: "Costo",
+ estimated: "Estimado",
+ in: "Entrada",
+ job_runs: "Ejecuciones",
+ last_n_days: "\xDAltimos {n} d\xEDas",
+ no_schedule: "Sin programaci\xF3n",
+ nominal: "Nominal",
+ out: "Salida",
+ pace: "Ritmo",
+ period: "per\xEDodo",
+ tokens: "Tokens",
+ trend: "Tendencia",
+ vs_prior: "vs anterior",
+ wasted: "Desperdiciado"
+ },
+ // tokens
+ tokens: {
+ what_this_means: "Los tokens son la unidad de uso de los LLMs. Los tokens de entrada son tus prompts + contexto. Los tokens de salida son la respuesta del modelo. Los tokens en cach\xE9 provienen de prompts repetidos con prefijos id\xE9nticos (m\xE1s econ\xF3micos)."
+ }
+ });
+
+ // dashboard/src/i18n/zh-CN.js
+ registerCatalog("zh", {
+ // cost
+ cost: {
+ trend_formula: "\u8D8B\u52BF % = ((\u5F53\u524D\u6210\u672C \u2212 \u4E0A\u671F\u6210\u672C) / \u4E0A\u671F\u6210\u672C) \xD7 100",
+ what_this_means: "\u9884\u4F30\u6210\u672C\u6839\u636E\u4EE4\u724C\u4F7F\u7528\u91CF\u548C\u6A21\u578B\u5B9A\u4EF7\u8BA1\u7B97\u3002\u5B9E\u9645\u6210\u672C\u53EF\u80FD\u56E0\u670D\u52A1\u5546\u8BA1\u8D39\u7C92\u5EA6\u800C\u7565\u6709\u5DEE\u5F02\u3002"
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "\u5E94\u7528\u81EA\u5B9A\u4E49\u5929\u6570",
+ go: "\u786E\u5B9A",
+ label: "\u5929\u6570"
+ },
+ // error
+ error: {
+ message: "\u51FA\u73B0\u95EE\u9898\uFF0C\u8BF7\u5237\u65B0\u9875\u9762\u6216\u8054\u7CFB\u652F\u6301\u3002",
+ title: "Cronalytics \u9519\u8BEF"
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "\u6536\u8D77\u6A2A\u5E45",
+ definition_1: "1. Cron \u5206\u6790\u4E0E\u53EF\u89C2\u6D4B\u6027\u3002",
+ definition_2: "2. Hermes \u4E2D\u667A\u80FD\u4EE3\u7406\u81EA\u52A8\u5316\u7684\u4EEA\u8868\u677F\u3002",
+ expand_tooltip: "\u5C55\u5F00\u6A2A\u5E45",
+ noun: "(\u540D\u8BCD)",
+ pronunciation: "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/",
+ tagline: "\u89C2\u5BDF\u3002\u8861\u91CF\u3002\u4F18\u5316\u3002",
+ title: "CRONALYTICS"
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "\u5347\u5E8F",
+ avg_est_cost: "\u5E73\u5747\u9884\u4F30\u6210\u672C",
+ avg_time: "\u5E73\u5747\u8017\u65F6",
+ descending: "\u964D\u5E8F",
+ est_cost: "\u9884\u4F30\u6210\u672C",
+ job: "\u4EFB\u52A1",
+ last: "\u4E0A\u6B21",
+ last_run: "\u4E0A\u6B21\u8FD0\u884C",
+ mode_agent: "\u667A\u80FD\u4F53",
+ mode_no_agent: "\u65E0\u667A\u80FD\u4F53",
+ next: "\u4E0B\u6B21",
+ no_jobs_sync: "\u672A\u6355\u83B7\u5230\u5B9A\u65F6\u4EFB\u52A1\u3002\u70B9\u51FB\u7ACB\u5373\u540C\u6B65\u4ECE state.db \u56DE\u586B\u3002",
+ no_jobs_window: "{window} \u5185\u65E0\u4EFB\u52A1\u3002\u4E0A\u6B21\u540C\u6B65\uFF1A{time} UTC",
+ no_schedule: "\u65E0\u8BA1\u5212",
+ nominal_mo: "\u6807\u79F0/\u6708",
+ pace: "\u6267\u884C\u7387",
+ runs: "\u8FD0\u884C",
+ schedule: "\u8BA1\u5212",
+ see_runs: "\u67E5\u770B\u8FD0\u884C",
+ sort_by: "\u6309 {col} \u6392\u5E8F",
+ sorted_by: "\u6309 {col} {dir} \u6392\u5E8F",
+ title: "\u4EFB\u52A1\u660E\u7EC6",
+ trend_mo: "\u8D8B\u52BF/\u6708",
+ using: "\u4F7F\u7528"
+ },
+ // job_detail
+ job_detail: {
+ duration: "\u8017\u65F6",
+ error_prefix: "\u9519\u8BEF\uFF1A",
+ est_cost: "\u9884\u4F30\u6210\u672C",
+ for_full_history: " \u67E5\u770B\u5B8C\u6574\u5386\u53F2\u3002",
+ loading: "\u52A0\u8F7D\u8FD0\u884C\u8BB0\u5F55...",
+ mode: "\u6A21\u5F0F",
+ mode_agent: "\u667A\u80FD\u4F53",
+ no_runs: "\u672A\u627E\u5230\u8FD0\u884C\u8BB0\u5F55\u3002",
+ of: " / ",
+ result: "\u7ED3\u679C",
+ run: "\u6B21\u8FD0\u884C",
+ runs_plural: "\u6B21\u8FD0\u884C",
+ showing: "\u663E\u793A ",
+ time: "\u65F6\u95F4",
+ title_runs: "\u8FD0\u884C\u8BB0\u5F55",
+ use_cli: "\u4F7F\u7528 "
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "\u6700\u4F73\u6267\u884C\u7387",
+ of_total_est_cost: "\u5360\u9884\u4F30\u603B\u6210\u672C %",
+ of_total_runs: "\u5360\u603B\u8FD0\u884C\u6570 %",
+ of_total_tokens: "\u5360\u603B\u4EE4\u724C\u6570 %",
+ title: "\u6392\u884C\u699C",
+ top_duration: "\u6700\u957F\u65F6\u957F",
+ top_est_cost: "\u6700\u9AD8\u6210\u672C",
+ top_runs: "\u6700\u591A\u8FD0\u884C",
+ top_tokens: "Token \u6700\u591A"
+ },
+ // modal
+ modal: {
+ close: "\u5173\u95ED"
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "\u667A\u80FD\u4F53",
+ all: "\u5168\u90E8",
+ label: "\u6A21\u5F0F",
+ no_agent: "\u65E0\u667A\u80FD\u4F53"
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "\u8FD8\u6709 {n} \u4E2A",
+ est_cost: "\u9884\u4F30\u6210\u672C",
+ model: "\u6A21\u578B",
+ runs: "\u8FD0\u884C",
+ title: "\u6A21\u578B\u5206\u5E03"
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "\u5168\u90E8",
+ failure: "\u5931\u8D25",
+ label: "\u7ED3\u679C",
+ success: "\u6210\u529F"
+ },
+ // pace
+ pace: {
+ nominal_formula: "\u6807\u79F0 = \u8BA1\u5212\u8FD0\u884C\u6B21\u6570 \xD7 \u6BCF\u6B21\u5E73\u5747\u6210\u672C",
+ pace_formula: "\u6267\u884C\u7387 = \u8D8B\u52BF / \u6807\u79F0",
+ trend_formula: "\u8D8B\u52BF = \u5B9E\u9645\u8FD0\u884C\u6B21\u6570 \xD7 \u6BCF\u6B21\u5E73\u5747\u6210\u672C",
+ what_this_means: "\u6267\u884C\u7387\u5C06\u4F60\u7684\u5B9E\u9645\u652F\u51FA\u8D8B\u52BF\u4E0E\u4F60\u5728\u5B9A\u65F6\u4EFB\u52A1\u5B9A\u4E49\u4E2D\u8BBE\u5B9A\u7684\u9884\u7B97\u8FDB\u884C\u6BD4\u8F83\u3002\u5B83\u56DE\u7B54\uFF1A\u2018\u6309\u7167\u8FD9\u4E2A\u901F\u5EA6\uFF0C\u6211\u662F\u8D85\u652F\u8FD8\u662F\u8282\u7EA6\uFF1F\u2019"
+ },
+ // runs
+ runs: {
+ trend_formula: "\u8D8B\u52BF % = ((\u5F53\u524D\u8FD0\u884C\u6570 \u2212 \u4E0A\u671F\u8FD0\u884C\u6570) / \u4E0A\u671F\u8FD0\u884C\u6570) \xD7 100",
+ trend_note: "\u6B63\u503C = \u6BD4\u4E0A\u671F\u8FD0\u884C\u66F4\u591A\u3002\u8D1F\u503C = \u6BD4\u4E0A\u671F\u8FD0\u884C\u66F4\u5C11\u3002",
+ what_this_means: "\u6240\u9009\u7A97\u53E3\u5185\u8BB0\u5F55\u7684\u5B9A\u65F6\u4EFB\u52A1\u6267\u884C\u603B\u6B21\u6570\u3002\u6BCF\u6B21\u8FD0\u884C\u90FD\u4F1A\u89E6\u53D1\u4F60\u7684\u8BA1\u5212\u4EFB\u52A1\u2014\u2014\u65E0\u8BBA\u6210\u529F\u3001\u5931\u8D25\u8FD8\u662F\u91CD\u8BD5\u3002"
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "\u4F7F\u7528\u6240\u9009\u7A97\u53E3\u6298\u7B97\u4E3A 30 \u5929\u3002",
+ breakdown: "\u660E\u7EC6",
+ color_guide: "\u989C\u8272\u8BF4\u660E",
+ green_under_budget: "\u7EFF\u8272 (< 1.0\xD7) \u2014 \u4F4E\u4E8E\u9884\u7B97\uFF0C\u652F\u51FA\u5C11\u4E8E\u8BA1\u5212\u3002",
+ hide: "\u9690\u85CF",
+ how_its_calculated: "\u5982\u4F55\u8BA1\u7B97",
+ job_details: "\u4EFB\u52A1\u8BE6\u60C5",
+ loading: "\u52A0\u8F7D\u4E2D\u2026",
+ neutral_budget: "\u4E2D\u6027 (1.0\u20132.0\xD7) \u2014 \u6B63\u5E38\u8303\u56F4\u5185\uFF0C\u8F7B\u5FAE\u6CE2\u52A8\u3002",
+ prior_window_note: "\u4E0A\u671F\u5BF9\u6BD4\u7A97\u53E3\u662F\u5C06\u76F8\u540C\u65F6\u957F\u5411\u540E\u5E73\u79FB\u6240\u5F97\u3002",
+ red_over_budget: "\u7EA2\u8272 (> 2.0\xD7) \u2014 \u8D85\u51FA\u9884\u7B97\uFF0C\u652F\u51FA\u591A\u4E8E\u8BA1\u5212\u3002",
+ refresh: "\u5237\u65B0",
+ retry: "\u91CD\u8BD5",
+ show: "\u663E\u793A",
+ showing_window: "\u663E\u793A ",
+ sync_now: "\u7ACB\u5373\u540C\u6B65",
+ synced_n_runs: "\u5DF2\u540C\u6B65 {n} \u6B21\u8FD0\u884C",
+ trend_calculation: "\u8D8B\u52BF\u8BA1\u7B97",
+ what_this_means: "\u8FD9\u662F\u4EC0\u4E48\u610F\u601D",
+ window_context: "\u7A97\u53E3\u4E0A\u4E0B\u6587"
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "\u2014 \u6210\u672C\uFF08\u67F1\u72B6\uFF09\xB7 ",
+ daily_cost: "\u6BCF\u65E5\u9884\u4F30\u6210\u672C",
+ daily_runs: "\u6BCF\u65E5\u8FD0\u884C",
+ duration_line: "- - \u65F6\u957F",
+ tokens_line: "\u2014 Token"
+ },
+ // summary
+ summary: {
+ actual: "\u5B9E\u9645",
+ all_time: "\u5168\u90E8\u65F6\u95F4",
+ cached: "\u7F13\u5B58",
+ cost: "\u6210\u672C",
+ estimated: "\u9884\u4F30",
+ in: "\u8F93\u5165",
+ job_runs: "\u4EFB\u52A1\u8FD0\u884C",
+ last_n_days: "\u6700\u8FD1 {n} \u5929",
+ no_schedule: "\u65E0\u8BA1\u5212",
+ nominal: "\u6807\u79F0",
+ out: "\u8F93\u51FA",
+ pace: "\u6267\u884C\u7387",
+ period: "\u5468\u671F",
+ tokens: "Token",
+ trend: "\u8D8B\u52BF",
+ vs_prior: "\u5BF9\u6BD4\u4E0A\u671F",
+ wasted: "\u6D6A\u8D39"
+ },
+ // tokens
+ tokens: {
+ what_this_means: "\u4EE4\u724C\u662F LLM \u4F7F\u7528\u7684\u8BA1\u91CF\u5355\u4F4D\u3002\u8F93\u5165\u4EE4\u724C\u662F\u4F60\u7684\u63D0\u793A\u8BCD + \u4E0A\u4E0B\u6587\u3002\u8F93\u51FA\u4EE4\u724C\u662F\u6A21\u578B\u7684\u54CD\u5E94\u3002\u7F13\u5B58\u4EE4\u724C\u6765\u81EA\u5177\u6709\u76F8\u540C\u524D\u7F00\u7684\u91CD\u590D\u63D0\u793A\u8BCD\uFF08\u66F4\u4FBF\u5B9C\uFF09\u3002"
+ }
+ });
+
+ // dashboard/src/i18n/zh-TW.js
+ registerCatalog("zh-TW", {
+ // cost
+ cost: {
+ trend_formula: "\u8DA8\u52E2 % = ((\u76EE\u524D\u6210\u672C \u2212 \u4E0A\u671F\u6210\u672C) / \u4E0A\u671F\u6210\u672C) \xD7 100",
+ what_this_means: "\u9810\u4F30\u6210\u672C\u6839\u64DA\u4EE4\u724C\u4F7F\u7528\u91CF\u548C\u6A21\u578B\u5B9A\u50F9\u8A08\u7B97\u3002\u5BE6\u969B\u6210\u672C\u53EF\u80FD\u56E0\u670D\u52D9\u5546\u8A08\u8CBB\u7C92\u5EA6\u800C\u7565\u6709\u5DEE\u7570\u3002"
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "\u5957\u7528\u81EA\u8A02\u5929\u6578",
+ go: "\u78BA\u5B9A",
+ label: "\u5929\u6578"
+ },
+ // error
+ error: {
+ message: "\u767C\u751F\u554F\u984C\uFF0C\u8ACB\u91CD\u65B0\u6574\u7406\u9801\u9762\u6216\u806F\u7D61\u652F\u63F4\u3002",
+ title: "Cronalytics \u932F\u8AA4"
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "\u6536\u5408\u6A6B\u5E45",
+ definition_1: "1. Cron \u5206\u6790\u8207\u53EF\u89C0\u6E2C\u6027\u3002",
+ definition_2: "2. Hermes \u4E2D\u667A\u80FD\u4EE3\u7406\u81EA\u52D5\u5316\u7684\u5100\u8868\u677F\u3002",
+ expand_tooltip: "\u5C55\u958B\u6A6B\u5E45",
+ noun: "(\u540D\u8A5E)",
+ pronunciation: "/\u02C8kr\u0252n.\u0259\u02CCl\u026At.\u026Aks/",
+ tagline: "\u89C0\u5BDF\u3002\u8861\u91CF\u3002\u6700\u4F73\u5316\u3002",
+ title: "CRONALYTICS"
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "\u905E\u589E",
+ avg_est_cost: "\u5E73\u5747\u9810\u4F30\u6210\u672C",
+ avg_time: "\u5E73\u5747\u8017\u6642",
+ descending: "\u905E\u6E1B",
+ est_cost: "\u9810\u4F30\u6210\u672C",
+ job: "\u4EFB\u52D9",
+ last: "\u4E0A\u6B21",
+ last_run: "\u4E0A\u6B21\u57F7\u884C",
+ mode_agent: "\u667A\u6167\u9AD4",
+ mode_no_agent: "\u7121\u667A\u80FD\u4EE3\u7406",
+ next: "\u4E0B\u6B21",
+ no_jobs_sync: "\u672A\u64F7\u53D6\u5230\u5B9A\u6642\u4EFB\u52D9\u3002\u9EDE\u64CA\u7ACB\u5373\u540C\u6B65\u5F9E state.db \u56DE\u586B\u3002",
+ no_jobs_window: "{window} \u5167\u7121\u4EFB\u52D9\u3002\u4E0A\u6B21\u540C\u6B65\uFF1A{time} UTC",
+ no_schedule: "\u7121\u6392\u7A0B",
+ nominal_mo: "\u6A19\u7A31/\u6708",
+ pace: "\u57F7\u884C\u7387",
+ runs: "\u57F7\u884C",
+ schedule: "\u6392\u7A0B",
+ see_runs: "\u67E5\u770B\u57F7\u884C",
+ sort_by: "\u6309 {col} \u6392\u5E8F",
+ sorted_by: "\u6309 {col} {dir} \u6392\u5E8F",
+ title: "\u4EFB\u52D9\u660E\u7D30",
+ trend_mo: "\u8DA8\u52E2/\u6708",
+ using: "\u4F7F\u7528"
+ },
+ // job_detail
+ job_detail: {
+ duration: "\u8017\u6642",
+ error_prefix: "\u932F\u8AA4\uFF1A",
+ est_cost: "\u9810\u4F30\u6210\u672C",
+ for_full_history: " \u67E5\u770B\u5B8C\u6574\u6B77\u7A0B\u3002",
+ loading: "\u8F09\u5165\u57F7\u884C\u8A18\u9304...",
+ mode: "\u6A21\u5F0F",
+ mode_agent: "\u667A\u6167\u9AD4",
+ no_runs: "\u672A\u627E\u5230\u57F7\u884C\u8A18\u9304\u3002",
+ of: " / ",
+ result: "\u7D50\u679C",
+ run: "\u6B21\u57F7\u884C",
+ runs_plural: "\u6B21\u57F7\u884C",
+ showing: "\u986F\u793A ",
+ time: "\u6642\u9593",
+ title_runs: "\u57F7\u884C\u8A18\u9304",
+ use_cli: "\u4F7F\u7528 "
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "\u6700\u4F73\u57F7\u884C\u7387",
+ of_total_est_cost: "\u4F54\u9810\u4F30\u7E3D\u6210\u672C %",
+ of_total_runs: "\u4F54\u7E3D\u57F7\u884C\u6578 %",
+ of_total_tokens: "\u4F54\u7E3D\u4EE4\u724C\u6578 %",
+ title: "\u6392\u884C\u699C",
+ top_duration: "\u6700\u9577\u6642\u9577",
+ top_est_cost: "\u6700\u9AD8\u6210\u672C",
+ top_runs: "\u6700\u591A\u57F7\u884C",
+ top_tokens: "Token \u6700\u591A"
+ },
+ // modal
+ modal: {
+ close: "\u95DC\u9589"
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "\u667A\u80FD\u4EE3\u7406",
+ all: "\u5168\u90E8",
+ label: "\u6A21\u5F0F",
+ no_agent: "\u7121\u667A\u80FD\u4EE3\u7406"
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "\u9084\u6709 {n} \u500B",
+ est_cost: "\u9810\u4F30\u6210\u672C",
+ model: "\u6A21\u578B",
+ runs: "\u57F7\u884C",
+ title: "\u6A21\u578B\u5206\u5E03"
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "\u5168\u90E8",
+ failure: "\u5931\u6557",
+ label: "\u7D50\u679C",
+ success: "\u6210\u529F"
+ },
+ // pace
+ pace: {
+ nominal_formula: "\u6A19\u7A31 = \u8A08\u756B\u57F7\u884C\u6B21\u6578 \xD7 \u6BCF\u6B21\u5E73\u5747\u6210\u672C",
+ pace_formula: "\u57F7\u884C\u7387 = \u8DA8\u52E2 / \u6A19\u7A31",
+ trend_formula: "\u8DA8\u52E2 = \u5BE6\u969B\u57F7\u884C\u6B21\u6578 \xD7 \u6BCF\u6B21\u5E73\u5747\u6210\u672C",
+ what_this_means: "\u57F7\u884C\u7387\u5C07\u4F60\u7684\u5BE6\u969B\u652F\u51FA\u8DA8\u52E2\u8207\u4F60\u5728\u5B9A\u6642\u4EFB\u52D9\u5B9A\u7FA9\u4E2D\u8A2D\u5B9A\u7684\u9810\u7B97\u9032\u884C\u6BD4\u8F03\u3002\u5B83\u56DE\u7B54\uFF1A\u2018\u6309\u7167\u9019\u500B\u901F\u5EA6\uFF0C\u6211\u662F\u8D85\u652F\u9084\u662F\u7BC0\u7D04\uFF1F\u2019"
+ },
+ // runs
+ runs: {
+ trend_formula: "\u8DA8\u52E2 % = ((\u76EE\u524D\u57F7\u884C\u6578 \u2212 \u4E0A\u671F\u57F7\u884C\u6578) / \u4E0A\u671F\u57F7\u884C\u6578) \xD7 100",
+ trend_note: "\u6B63\u503C = \u6BD4\u4E0A\u671F\u57F7\u884C\u66F4\u591A\u3002\u8CA0\u503C = \u6BD4\u4E0A\u671F\u57F7\u884C\u66F4\u5C11\u3002",
+ what_this_means: "\u6240\u9078\u8996\u7A97\u5167\u8A18\u9304\u7684\u5B9A\u6642\u4EFB\u52D9\u57F7\u884C\u7E3D\u6B21\u6578\u3002\u6BCF\u6B21\u57F7\u884C\u90FD\u6703\u89F8\u767C\u4F60\u7684\u8A08\u756B\u4EFB\u52D9\u2014\u2014\u7121\u8AD6\u6210\u529F\u3001\u5931\u6557\u9084\u662F\u91CD\u8A66\u3002"
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "\u4F7F\u7528\u6240\u9078\u8996\u7A97\u6298\u7B97\u70BA 30 \u5929\u3002",
+ breakdown: "\u660E\u7D30",
+ color_guide: "\u984F\u8272\u8AAA\u660E",
+ green_under_budget: "\u7DA0\u8272 (< 1.0\xD7) \u2014 \u4F4E\u65BC\u9810\u7B97\uFF0C\u652F\u51FA\u5C11\u65BC\u8A08\u756B\u3002",
+ hide: "\u96B1\u85CF",
+ how_its_calculated: "\u5982\u4F55\u8A08\u7B97",
+ job_details: "\u4EFB\u52D9\u8A73\u60C5",
+ loading: "\u8F09\u5165\u4E2D\u2026",
+ neutral_budget: "\u4E2D\u6027 (1.0\u20132.0\xD7) \u2014 \u6B63\u5E38\u7BC4\u570D\u5167\uFF0C\u8F15\u5FAE\u6CE2\u52D5\u3002",
+ prior_window_note: "\u4E0A\u671F\u5C0D\u6BD4\u8996\u7A97\u662F\u5C07\u76F8\u540C\u6642\u9577\u5411\u5F8C\u5E73\u79FB\u6240\u5F97\u3002",
+ red_over_budget: "\u7D05\u8272 (> 2.0\xD7) \u2014 \u8D85\u51FA\u9810\u7B97\uFF0C\u652F\u51FA\u591A\u65BC\u8A08\u756B\u3002",
+ refresh: "\u91CD\u65B0\u6574\u7406",
+ retry: "\u91CD\u8A66",
+ show: "\u986F\u793A",
+ showing_window: "\u986F\u793A ",
+ sync_now: "\u7ACB\u5373\u540C\u6B65",
+ synced_n_runs: "\u5DF2\u540C\u6B65 {n} \u6B21\u57F7\u884C",
+ trend_calculation: "\u8DA8\u52E2\u8A08\u7B97",
+ what_this_means: "\u9019\u662F\u4EC0\u9EBC\u610F\u601D",
+ window_context: "\u8996\u7A97\u4E0A\u4E0B\u6587"
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "\u2014 \u6210\u672C\uFF08\u67F1\u72C0\uFF09\xB7 ",
+ daily_cost: "\u6BCF\u65E5\u9810\u4F30\u6210\u672C",
+ daily_runs: "\u6BCF\u65E5\u57F7\u884C",
+ duration_line: "- - \u6642\u9577",
+ tokens_line: "\u2014 Token"
+ },
+ // summary
+ summary: {
+ actual: "\u5BE6\u969B",
+ all_time: "\u5168\u90E8\u6642\u9593",
+ cached: "\u5FEB\u53D6",
+ cost: "\u6210\u672C",
+ estimated: "\u9810\u4F30",
+ in: "\u8F38\u5165",
+ job_runs: "\u4EFB\u52D9\u57F7\u884C",
+ last_n_days: "\u6700\u8FD1 {n} \u5929",
+ no_schedule: "\u7121\u6392\u7A0B",
+ nominal: "\u6A19\u7A31",
+ out: "\u8F38\u51FA",
+ pace: "\u57F7\u884C\u7387",
+ period: "\u9031\u671F",
+ tokens: "Token",
+ trend: "\u8DA8\u52E2",
+ vs_prior: "\u5C0D\u6BD4\u4E0A\u671F",
+ wasted: "\u6D6A\u8CBB"
+ },
+ // tokens
+ tokens: {
+ what_this_means: "\u4EE4\u724C\u662F LLM \u4F7F\u7528\u7684\u8A08\u91CF\u55AE\u4F4D\u3002\u8F38\u5165\u4EE4\u724C\u662F\u4F60\u7684\u63D0\u793A\u8A5E + \u4E0A\u4E0B\u6587\u3002\u8F38\u51FA\u4EE4\u724C\u662F\u6A21\u578B\u7684\u56DE\u61C9\u3002\u5FEB\u53D6\u4EE4\u724C\u4F86\u81EA\u5177\u6709\u76F8\u540C\u524D\u7DB4\u7684\u91CD\u8907\u63D0\u793A\u8A5E\uFF08\u66F4\u4FBF\u5B9C\uFF09\u3002"
+ }
+ });
- // src/index.js
+ // dashboard/src/index.js
PLUGINS.register("cronalytics", function CronalyticsWrapped() {
return React.createElement(
PluginErrorBoundary,
diff --git a/dashboard/manifest.json b/dashboard/manifest.json
index 6ca6170..b23d147 100644
--- a/dashboard/manifest.json
+++ b/dashboard/manifest.json
@@ -3,7 +3,7 @@
"label": "Cronalytics",
"description": "Cost and operational observability for Hermes cron jobs",
"icon": "Clock",
- "version": "0.1.0",
+ "version": "1.1.0",
"tab": {
"path": "/cronalytics",
"position": "end",
diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py
index 5f03999..7425d62 100644
--- a/dashboard/plugin_api.py
+++ b/dashboard/plugin_api.py
@@ -14,6 +14,7 @@
import json
import sqlite3
import sys
+import time
from pathlib import Path
from typing import Any
@@ -28,9 +29,9 @@
def _load_module(name: str):
- """Load a .py file from the plugin root as a namespaced module."""
+ """Load a .py file from the plugin package as a namespaced module."""
mod_name = f"cronalytics_auto_{name}"
- path = _plugin_dir / f"{name}.py"
+ path = _plugin_dir / "cronalytics" / f"{name}.py"
spec = importlib.util.spec_from_file_location(mod_name, path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load {path}")
@@ -127,7 +128,7 @@ async def health() -> dict[str, Any]:
"status": "ok",
"fact_db": db_health,
"sync": sync_status,
- "version": "1.0.0",
+ "version": "1.1.0",
}
)
@@ -147,7 +148,7 @@ async def sync() -> dict[str, Any]:
@router.get("/summary")
async def summary(
days: int = Query(default=30, ge=0),
- outcome: str = Query(default="both", pattern="^(both|success|failure)$"),
+ outcome: str = Query(default="all", pattern="^(all|both|success|failure)$"),
mode: str = Query(default="all", pattern="^(all|agent|no_agent)$"),
) -> dict[str, Any]:
"""Aggregated stats for cron runs over the last N days (0 = all time).
@@ -163,8 +164,8 @@ async def summary(
for j in raw_jobs:
proj = _schedule_mod.get_job_projections(
job_id=j["job_id"],
- avg_cost=j.get("avg_cost"),
- total_cost=j.get("total_cost"),
+ avg_estimated_cost=j.get("avg_estimated_cost"),
+ tot_estimated_cost=j.get("tot_estimated_cost"),
runs=j.get("runs", 0),
first_run=j.get("first_run", 0),
last_run=j.get("last_run", 0),
@@ -199,7 +200,7 @@ async def summary(
async def jobs(
days: int = Query(default=30, ge=0),
skip_projections: bool = Query(default=False, description="Set true to omit schedule-aware projections (faster)"),
- outcome: str = Query(default="both", pattern="^(both|success|failure)$"),
+ outcome: str = Query(default="all", pattern="^(all|both|success|failure)$"),
mode: str = Query(default="all", pattern="^(all|agent|no_agent)$"),
) -> dict[str, Any]:
"""Per-job aggregates: runs, total cost, avg cost, projections (0 = all time)."""
@@ -210,8 +211,8 @@ async def jobs(
for j in enriched:
proj = _schedule_mod.get_job_projections(
job_id=j["job_id"],
- avg_cost=j.get("avg_cost"),
- total_cost=j.get("total_cost"),
+ avg_estimated_cost=j.get("avg_estimated_cost"),
+ tot_estimated_cost=j.get("tot_estimated_cost"),
runs=j.get("runs", 0),
first_run=j.get("first_run", 0),
last_run=j.get("last_run", 0),
@@ -231,19 +232,19 @@ async def jobs(
@router.get("/jobs/{job_id}/runs")
async def job_runs(
job_id: str,
- limit: int = Query(default=50, ge=1, le=500),
+ limit: int = Query(default=250, ge=0, le=500),
days: int = Query(default=0, ge=0),
- outcome: str = Query(default="both", pattern="^(both|success|failure)$"),
+ outcome: str = Query(default="all", pattern="^(all|both|success|failure)$"),
sort_key: str = Query(
default="run_time",
- pattern="^(run_time|estimated_cost_usd|duration_seconds|success|model|input_tokens)$",
+ pattern="^(run_time|estimated_cost|duration_seconds|success|model|input_tokens|job_mode)$",
),
sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
mode: str = Query(default="all", pattern="^(all|agent|no_agent)$"),
) -> dict[str, Any]:
"""Individual run history for a specific job (0 = all time).
- Inherits the global outcome filter and allows sorting by cost, duration, model, success,
+ Inherits the global outcome filter and allows sorting by estimated_cost, duration, model, success,
or time. Defaults to the parent table's sort preference if passed through sort_key.
"""
rows = _facts_mod.query_job_runs(
@@ -252,10 +253,31 @@ async def job_runs(
)
if not rows:
raise HTTPException(status_code=404, detail=f"No runs found for job {job_id}")
+ # Also fetch total count for "more available" indicator
+ total_conn = sqlite3.connect(FACT_DB)
+ total_conditions = ["job_id = ?"]
+ total_params: list[Any] = [job_id]
+ if days > 0:
+ total_conditions.append("run_time >= ?")
+ total_params.append(time.time() - (days * 86400))
+ if outcome in ("success", "failure"):
+ total_conditions.append("success = ?")
+ total_params.append(1 if outcome == "success" else 0)
+ if mode in ("agent", "no_agent"):
+ total_conditions.append("job_mode = ?")
+ total_params.append(mode)
+ total_where = " WHERE " + " AND ".join(total_conditions)
+ total_cursor = total_conn.execute(
+ f"SELECT COUNT(*) FROM cron_runs{total_where}", total_params
+ )
+ total_runs = total_cursor.fetchone()[0]
+
return _api_wrap(
{
"job_id": job_id,
"limit": limit,
+ "total_runs": total_runs,
+ "more_available": total_runs > len(rows),
"days": days,
"outcome": outcome,
"sort_key": sort_key,
@@ -268,7 +290,7 @@ async def job_runs(
@router.get("/models")
async def models(
days: int = Query(default=30, ge=0),
- outcome: str = Query(default="both", pattern="^(both|success|failure)$"),
+ outcome: str = Query(default="all", pattern="^(all|both|success|failure)$"),
mode: str = Query(default="all", pattern="^(all|agent|no_agent)$"),
) -> dict[str, Any]:
"""Per-model usage aggregates (0 = all time)."""
@@ -284,7 +306,7 @@ async def models(
@router.get("/trends")
async def trends(
days: int = Query(default=30, ge=0),
- outcome: str = Query(default="both", pattern="^(both|success|failure)$"),
+ outcome: str = Query(default="all", pattern="^(all|both|success|failure)$"),
mode: str = Query(default="all", pattern="^(all|agent|no_agent)$"),
) -> dict[str, Any]:
"""Daily cost trend (0 = all time)."""
diff --git a/dashboard/src/components/CronalyticsTab.js b/dashboard/src/components/CronalyticsTab.js
index e12892c..cdae6a1 100644
--- a/dashboard/src/components/CronalyticsTab.js
+++ b/dashboard/src/components/CronalyticsTab.js
@@ -4,7 +4,6 @@ import { Modal } from "../components/Modal.js";
import { DaySelector } from "../components/DaySelector.js";
import { OutcomeToggle } from "../components/OutcomeToggle.js";
import { ModeToggle } from "../components/ModeToggle.js";
-import { SparkLine } from "../components/SparkLine.js";
import { JobDetailView } from "../components/JobDetailView.js";
import { HeroBanner } from "../components/HeroBanner.js";
import { SummaryBoard } from "../components/SummaryBoard.js";
@@ -13,8 +12,10 @@ import { ModelBreakdown } from "../components/ModelBreakdown.js";
import { JobBreakdown } from "../components/JobBreakdown.js";
import { fmtCost, fmtTime, fmtCompact, fmtDuration, fmtSyncAge, paceColor } from "../lib/formatters.js";
import { RefreshCwIcon } from "../lib/icons.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function CronalyticsTab() {
+ const t = useCronalyticsI18n();
const [days, setDaysRaw] = useState(() => {
try {
const saved = localStorage.getItem("cronalytics:days");
@@ -29,9 +30,15 @@ export function CronalyticsTab() {
const [outcome, setOutcomeRaw] = useState(() => {
try {
const saved = localStorage.getItem("cronalytics:outcome");
- if (saved) return saved;
+ if (saved) {
+ if (saved === "both") {
+ localStorage.setItem("cronalytics:outcome", "all");
+ return "all";
+ }
+ return saved;
+ }
} catch {}
- return "both";
+ return "all";
});
const setOutcome = (v) => {
try { localStorage.setItem("cronalytics:outcome", v); } catch {}
@@ -114,7 +121,7 @@ export function CronalyticsTab() {
setSyncing(false);
if (syncResult && syncResult.result) {
const { inserted, elapsed_ms } = syncResult.result;
- setSyncToast({ msg: "\u2713 Synced " + inserted + " runs \u00b7 " + (elapsed_ms / 1000).toFixed(1) + "s" });
+ setSyncToast({ msg: "\u2713 " + t("shared.synced_n_runs", "Synced {n} runs") + " \u00b7 " + (elapsed_ms / 1000).toFixed(1) + "s", n: inserted });
setTimeout(() => setSyncToast(null), 5000);
}
summary.refetch();
@@ -129,16 +136,16 @@ export function CronalyticsTab() {
if (summary.error || jobs.error) {
return React.createElement("div", { style: { padding: "0 0.25rem 1rem 0", color: "var(--color-destructive)" } },
- "Error: " + (summary.error || jobs.error)
+ t("job_detail.error_prefix", "Error: ") + (summary.error || jobs.error)
);
}
const s = summary.data || {};
const jobList = (jobs.data && jobs.data.jobs) ? jobs.data.jobs : [];
- const windowLabel = days === 0 ? "All time" : "Last " + days + " days";
+ const windowLabel = days === 0 ? t("summary.all_time", "All time") : t("summary.last_n_days", "Last {n} days", { n: days });
- const costPct = s.previous_period && s.previous_period.cost != null && s.previous_period.cost !== 0
- ? ((s.total_estimated_cost - s.previous_period.cost) / s.previous_period.cost) * 100
+ const costPct = s.previous_period && s.previous_period.estimated_cost != null && s.previous_period.estimated_cost !== 0
+ ? ((s.tot_estimated_cost - s.previous_period.estimated_cost) / s.previous_period.estimated_cost) * 100
: null;
const runPct = s.previous_period && s.previous_period.runs != null && s.previous_period.runs !== 0
? ((s.total_runs - s.previous_period.runs) / s.previous_period.runs) * 100
@@ -146,14 +153,15 @@ export function CronalyticsTab() {
const getSortValue = (j, key) => {
switch (key) {
- case "Job": return j.name || j.job_id;
- case "Runs": return j.runs || 0;
- case "Avg Time": return j.avg_duration || 0;
- case "Total Cost": return j.total_cost || 0;
- case "Avg Cost": return j.avg_cost || 0;
- case "Nominal/mo": return j.projections && j.projections.projected_cost_30d != null ? j.projections.projected_cost_30d : -Infinity;
- case "Trend/mo": return j.projections && j.projections.trend_projected_cost_30d != null ? j.projections.trend_projected_cost_30d : -Infinity;
- case "Pace": return j.projections && j.projections.pace != null ? j.projections.pace : -Infinity;
+ case t("job_breakdown.job", "Job"): return j.name || j.job_id;
+ case t("job_breakdown.runs", "Runs"): return j.runs || 0;
+ case t("job_breakdown.avg_time", "Avg Duration"): return j.avg_duration || 0;
+ case t("job_breakdown.est_cost", "Est Cost"): return j.tot_estimated_cost || 0;
+ case t("job_breakdown.avg_est_cost", "Avg Est Cost"): return j.avg_estimated_cost || 0;
+ case t("job_breakdown.nominal_mo", "Nominal/mo"): return j.projections && j.projections.projected_cost_30d != null ? j.projections.projected_cost_30d : -Infinity;
+ case t("job_breakdown.trend_mo", "Trend/mo"): return j.projections && j.projections.trend_projected_cost_30d != null ? j.projections.trend_projected_cost_30d : -Infinity;
+ case t("job_breakdown.pace", "Pace"): return j.projections && j.projections.pace != null ? j.projections.pace : -Infinity;
+
default: return 0;
}
};
@@ -200,16 +208,14 @@ export function CronalyticsTab() {
},
// Toggles group — nowrap so they stay together as one unit.
React.createElement("div", { style: { display: "flex", flexWrap: "nowrap", gap: "0.75rem", alignItems: "center" } },
- React.createElement(OutcomeToggle, { selected: outcome, onChange: setOutcome, label: "Outcomes" }),
- React.createElement(ModeToggle, { selected: mode, onChange: setMode, label: "Mode" }),
+ React.createElement(OutcomeToggle, { selected: outcome, onChange: setOutcome, label: t("outcome_toggle.label", "Outcomes") }),
+ React.createElement(ModeToggle, { selected: mode, onChange: setMode, label: t("mode_toggle.label", "Mode") }),
),
// Spacer pushes DaySelector + Refresh to the right edge.
- // Using a flex child instead of marginLeft: auto so that when items wrap,
- // each wrapped line starts from the left and uses full toolbar width.
React.createElement("div", { style: { flex: "1 1 0%", minWidth: "0.25rem" } }),
// DaySelector returns [label, presets, custom] — flattened as direct flex children
// of the toolbar so presets, custom input, and Refresh wrap progressively.
- React.createElement(DaySelector, { selected: days, onChange: setDays, label: "Days" }),
+ React.createElement(DaySelector, { selected: days, onChange: setDays, label: null }),
// Refresh — its own flex item so it breaks away first at 110%.
React.createElement(Button, {
type: "button",
@@ -217,12 +223,11 @@ export function CronalyticsTab() {
outlined: true,
disabled: summary.loading || jobs.loading,
onClick: () => { summary.refetch(); jobs.refetch(); },
+ title: "Refresh",
+ style: { minHeight: "28px", display: "flex", alignItems: "center", justifyContent: "center" },
}, summary.loading || jobs.loading
- ? "\u2026"
- : React.createElement("span", { style: { display: "flex", alignItems: "center", gap: "0.25rem" } },
- RefreshCwIcon(14),
- "Refresh"
- )
+ ? RefreshCwIcon(14, { style: { animation: "cronalytics-spin 1s linear infinite" } })
+ : RefreshCwIcon(14)
),
),
@@ -237,7 +242,8 @@ export function CronalyticsTab() {
jobName: (jobList.find(j => j.job_id === selectedJobId) || {}).name,
days: days,
outcome: outcome,
- sortKey: ({"Job":"run_time","Runs":"run_time","Avg Time":"duration_seconds","Total Cost":"estimated_cost_usd","Avg Cost":"estimated_cost_usd","Nominal/mo":"run_time","Trend/mo":"run_time","Pace":"run_time"}[sortConfig.key] || "run_time"),
+ sortKey: ({[t("job_breakdown.job", "Job")]:"run_time",[t("job_breakdown.runs", "Runs")]:"run_time",[t("job_breakdown.avg_time", "Avg Duration")]:"duration_seconds",[t("job_breakdown.est_cost", "Est Cost")]:"estimated_cost",[t("job_breakdown.avg_est_cost", "Avg Est Cost")]:"estimated_cost",[t("job_breakdown.nominal_mo", "Nominal/mo")]:"run_time",[t("job_breakdown.trend_mo", "Trend/mo")]:"run_time",[t("job_breakdown.pace", "Pace")]:"run_time"}[sortConfig.key] || "run_time"),
+
sortDir: sortConfig.direction || "desc",
})),
@@ -266,19 +272,19 @@ export function CronalyticsTab() {
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: paceColor(s.pace) } },
s.pace != null ? s.pace.toFixed(2) + "\u00d7" : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Pace")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.pace", "Pace"))
),
React.createElement("div", { style: { marginBottom: "1rem" } },
React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "0.2rem" } },
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, "Nominal"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, t("summary.nominal", "Nominal")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
React.createElement("div", { style: { width: (Math.min(100, ((s.nominal_monthly_total || 1) / Math.max(s.nominal_monthly_total || 1, s.trend_monthly_total || 1, 1)) * 100)) + "%", background: "#4ade80", height: "100%", opacity: 0.8 } })
),
React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem", color: "#4ade80" } }, fmtCost(s.nominal_monthly_total) + "/mo")
),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, "Trend"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.8rem" } }, t("summary.trend", "Trend")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
React.createElement("div", { style: { width: (Math.min(100, ((s.trend_monthly_total || 1) / Math.max(s.nominal_monthly_total || 1, s.trend_monthly_total || 1, 1)) * 100)) + "%", background: "#ef4444", height: "100%", opacity: 0.8 } })
),
@@ -286,34 +292,7 @@ export function CronalyticsTab() {
)
)
),
- React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem", textTransform: "none" } },
- "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019"
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "How it\u2019s calculated"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6, textTransform: "none" } },
- React.createElement("div", null, "Nominal = scheduled runs \u00d7 average cost per run"),
- React.createElement("div", null, "Trend = actual runs \u00d7 average cost per run"),
- React.createElement("div", null, "Pace = Trend / Nominal"),
- React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6, textTransform: "none" } }, "All scaled to a 30\u2011day month using the selected window.")
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Color guide"),
- React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "0.35rem", fontSize: "0.78rem", marginBottom: "0.75rem", textTransform: "none" } },
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#4ade80" } }),
- React.createElement("span", null, "Green (< 1.0\u00d7) \u2014 Under budget. Spending less than scheduled.")
- ),
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "var(--foreground)" } }),
- React.createElement("span", null, "Neutral (1.0\u20132.0\u00d7) \u2014 On track. Slight variance within normal range.")
- ),
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
- React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#ef4444" } }),
- React.createElement("span", null, "Red (\u2265 2.0\u00d7) \u2014 Over budget. Actual spend is double (or more) the nominal rate.")
- )
- )
- )
+ React.createElement(PaceExplainer, { s, windowLabel, t })
)
),
@@ -324,28 +303,14 @@ export function CronalyticsTab() {
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)" } },
(s.total_runs || 0).toLocaleString()
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Job Runs")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.job_runs", "Job Runs"))
),
runPct != null && React.createElement("div", { style: { marginBottom: "1rem" } },
React.createElement("div", { style: { fontSize: "0.82rem", color: runPct > 0 ? "#ef4444" : "#4ade80" } },
- (runPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(runPct).toFixed(0) + "% vs prior " + (days === 0 ? "period" : days + "d")
+ (runPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(runPct).toFixed(0) + "% " + t("summary.vs_prior", "vs prior") + " " + (days === 0 ? t("summary.period", "period") : days + "d")
)
),
- React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Trend calculation"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Trend % = ((current runs \u2212 prior runs) / prior runs) \u00d7 100"),
- React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6 } }, "Positive = more runs than the prior window. Negative = fewer runs."),
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Window context"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
- "Showing ", React.createElement("strong", null, windowLabel), ". The prior comparison window is the same duration shifted back in time."
- )
- )
+ React.createElement(RunsExplainer, { windowLabel, t })
)
),
@@ -354,32 +319,19 @@ export function CronalyticsTab() {
React.createElement("div", { style: { padding: "1.5rem", fontFamily: "var(--theme-font-mono, monospace)", textTransform: "none" } },
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } },
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" } },
- fmtCost(s.total_estimated_cost)
+ fmtCost(s.tot_estimated_cost)
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Estimated Cost")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.estimated", "Estimated") + " " + t("summary.cost", "Cost"))
),
- s.total_actual_cost != null && React.createElement("div", { style: { marginBottom: "0.75rem", fontSize: "0.8rem", opacity: 0.85 } },
- "Actual: ", React.createElement("span", { style: { fontWeight: 700 } }, fmtCost(s.total_actual_cost))
+ s.tot_actual_cost != null && React.createElement("div", { style: { marginBottom: "0.75rem", fontSize: "0.8rem", opacity: 0.85 } },
+ t("summary.actual", "Actual") + ": ", React.createElement("span", { style: { fontWeight: 700 } }, fmtCost(s.tot_actual_cost))
),
costPct != null && React.createElement("div", { style: { marginBottom: "1rem" } },
React.createElement("div", { style: { fontSize: "0.82rem", color: costPct > 0 ? "#ef4444" : "#4ade80" } },
- (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "% vs prior " + (days === 0 ? "period" : days + "d")
+ (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "% " + t("summary.vs_prior", "vs prior") + " " + (days === 0 ? t("summary.period", "period") : days + "d")
)
),
- React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Trend calculation"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Trend % = ((current cost \u2212 prior cost) / prior cost) \u00d7 100"),
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Window context"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
- "Showing ", React.createElement("strong", null, windowLabel), ". The prior comparison window is the same duration shifted back in time."
- )
- )
+ React.createElement(CostExplainer, { windowLabel, t })
)
),
@@ -390,43 +342,32 @@ export function CronalyticsTab() {
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#5b8def" } },
fmtCompact(s.total_tokens)
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "Tokens")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.tokens", "Tokens"))
),
React.createElement("div", { style: { marginBottom: "1rem", display: "flex", flexDirection: "column", gap: "0.2rem" } },
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "In"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.in", "In")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
React.createElement("div", { style: { width: Math.min(100, ((s.total_input_tokens || 0) / (s.total_tokens || 1)) * 100) + "%", background: "#5b8def", height: "100%", opacity: 0.8 } })
),
React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem" } }, fmtCompact(s.total_input_tokens))
),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "Out"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.out", "Out")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
React.createElement("div", { style: { width: Math.min(100, ((s.total_output_tokens || 0) / (s.total_tokens || 1)) * 100) + "%", background: "#5b8def", height: "100%", opacity: 0.8 } })
),
React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem" } }, fmtCompact(s.total_output_tokens))
),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, "Cached"),
+ React.createElement("span", { style: { width: "4rem", fontSize: "0.8rem" } }, t("summary.cached", "Cached")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
React.createElement("div", { style: { width: Math.min(100, ((s.total_cache_read_tokens || 0) / (s.total_tokens || 1)) * 100) + "%", background: "#5b8def", height: "100%", opacity: 0.8 } })
),
React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem" } }, fmtCompact(s.total_cache_read_tokens))
)
),
- React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "What this means"),
- React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
- "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper)."
- ),
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Breakdown"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Input: " + fmtCompact(s.total_input_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_input_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
- React.createElement("div", null, "Output: " + fmtCompact(s.total_output_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_output_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
- React.createElement("div", null, "Cached: " + fmtCompact(s.total_cache_read_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_cache_read_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)")
- )
- )
+ React.createElement(TokensExplainer, { s, t })
)
),
@@ -442,18 +383,9 @@ export function CronalyticsTab() {
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)" } },
j ? (j.runs || 0).toLocaleString() : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "runs")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.job_runs", "Job Runs"))
),
- j && React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + ((j.schedule && j.schedule.display) || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Avg duration: " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014")),
- React.createElement("div", null, "Tokens: " + (j.total_tokens != null ? fmtCompact(j.total_tokens) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -463,26 +395,17 @@ export function CronalyticsTab() {
React.createElement(Modal, { isOpen: topCostModal.isOpen, onClose: topCostModal.close },
React.createElement("div", { style: { padding: "1.5rem", fontFamily: "var(--theme-font-mono, monospace)", textTransform: "none" } },
(() => {
- const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.total_cost || 0) > (a.total_cost || 0) ? b : a, jobList[0]) : null;
+ const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.tot_estimated_cost || 0) > (a.tot_estimated_cost || 0) ? b : a, jobList[0]) : null;
const label = j ? (j.name || j.job_id) : "\u2014";
return React.createElement("div", null,
React.createElement("div", { style: { fontSize: "1.1rem", fontWeight: 700, marginBottom: "0.5rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, label),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } },
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" } },
- j ? fmtCost(j.total_cost) : "\u2014"
+ j ? fmtCost(j.tot_estimated_cost) : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "total cost")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.estimated", "Estimated") + " " + t("summary.cost", "Cost"))
),
- j && React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + ((j.schedule && j.schedule.display) || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Avg duration: " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString() + " \u00b7 Avg: " + (j.avg_cost != null ? fmtCost(j.avg_cost) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -500,17 +423,9 @@ export function CronalyticsTab() {
React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#5b8def" } },
j ? fmtCompact(j.total_tokens) : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "tokens")
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.tokens", "Tokens"))
),
- j && React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + ((j.schedule && j.schedule.display) || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString())
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
@@ -520,60 +435,47 @@ export function CronalyticsTab() {
React.createElement(Modal, { isOpen: topPaceModal.isOpen, onClose: topPaceModal.close },
React.createElement("div", { style: { padding: "1.5rem", fontFamily: "var(--theme-font-mono, monospace)", textTransform: "none" } },
(() => {
- const j = jobList.length > 0
- ? jobList.reduce((a, b) => {
- const aPace = (a.projections && a.projections.pace != null) ? a.projections.pace : -Infinity;
- const bPace = (b.projections && b.projections.pace != null) ? b.projections.pace : -Infinity;
- return bPace > aPace ? b : a;
- }, jobList[0])
- : null;
+ const j = jobList.length > 0 ? jobList.reduce((a, b) => {
+ const aPace = (a.projections && a.projections.pace != null) ? a.projections.pace : -Infinity;
+ const bPace = (b.projections && b.projections.pace != null) ? b.projections.pace : -Infinity;
+ return bPace > aPace ? b : a;
+ }, jobList[0]) : null;
const label = j ? (j.name || j.job_id) : "\u2014";
const p = j && j.projections && j.projections.pace != null ? j.projections.pace : null;
return React.createElement("div", null,
React.createElement("div", { style: { fontSize: "1.1rem", fontWeight: 700, marginBottom: "0.5rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, label),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "1rem" } },
- React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: paceColor(p) } },
+ React.createElement("span", { style: { fontSize: "1.75rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: p != null ? paceColor(p) : null } },
p != null ? p.toFixed(2) + "\u00d7" : "\u2014"
),
- React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, "pace")
- ),
- React.createElement("div", { style: { marginBottom: "1rem" } },
- React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "0.2rem" } },
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "5rem", fontSize: "0.8rem" } }, "Nominal/mo"),
- React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
- React.createElement("div", { style: { width: Math.min(100, ((j && j.projections && j.projections.projected_cost_30d || 1) / Math.max((j && j.projections && j.projections.projected_cost_30d) || 1, (j && j.projections && j.projections.trend_projected_cost_30d) || 1, 1)) * 100) + "%", background: "#4ade80", height: "100%", opacity: 0.8 } })
- ),
- React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem", color: "#4ade80" } }, fmtCost(j && j.projections ? j.projections.projected_cost_30d : null) + "/mo")
- ),
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "5rem", fontSize: "0.8rem" } }, "Trend/mo"),
- React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.35rem", overflow: "hidden" } },
- React.createElement("div", { style: { width: Math.min(100, ((j && j.projections && j.projections.trend_projected_cost_30d || 1) / Math.max((j && j.projections && j.projections.projected_cost_30d) || 1, (j && j.projections && j.projections.trend_projected_cost_30d) || 1, 1)) * 100) + "%", background: "#ef4444", height: "100%", opacity: 0.8 } })
- ),
- React.createElement("span", { style: { width: "5.5rem", textAlign: "right", fontSize: "0.8rem", color: "#ef4444" } }, fmtCost(j && j.projections ? j.projections.trend_projected_cost_30d : null) + "/mo")
- )
- )
+ React.createElement("span", { style: { fontSize: "0.9rem", opacity: 0.8, fontWeight: 900 } }, t("summary.pace", "Pace"))
),
- j && React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
- React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, "Job details"),
- React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
- React.createElement("div", null, "Schedule: " + ((j.schedule && j.schedule.display) || "\u2014")),
- React.createElement("div", null, "Last run: " + fmtTime(j.last_run)),
- React.createElement("div", null, "Model: " + (j.last_model || "\u2014")),
- React.createElement("div", null, "Runs: " + (j.runs || 0).toLocaleString() + " \u00b7 Avg cost: " + (j.avg_cost != null ? fmtCost(j.avg_cost) : "\u2014"))
- )
- )
+ j && React.createElement(JobDetailsBlock, { j, t })
);
})()
)
),
+ // ModelBreakdown — full width
React.createElement(ModelBreakdown, { costByModel: s.cost_by_model }),
- mode === "all" && s.script_jobs_in_window > 0 && React.createElement("div", {
- style: { fontSize: "0.65rem", opacity: 0.45, fontFamily: "var(--theme-font-mono, monospace)", marginBottom: "0.5rem", paddingLeft: "0.25rem" }
- }, s.script_jobs_in_window + " no-agent job" + (s.script_jobs_in_window === 1 ? "" : "s") + " at $0.00 included. Filter to isolate agent costs."),
+ // Toast
+ syncToast && React.createElement("div", {
+ style: {
+ position: "fixed",
+ bottom: 24,
+ right: 24,
+ zIndex: 2000,
+ fontSize: "0.72rem",
+ fontFamily: "var(--theme-font-mono, monospace)",
+ padding: "0.5rem 0.75rem",
+ borderRadius: "0.35rem",
+ background: "rgba(34,197,94,0.12)",
+ border: "1px solid rgba(34,197,94,0.3)",
+ color: "#4ade80",
+ animation: "cronalytics-fadein 0.3s ease"
+ }
+ }, syncToast.msg),
React.createElement(JobBreakdown, {
jobList,
@@ -585,29 +487,110 @@ export function CronalyticsTab() {
days,
windowLabel,
onSync,
- onSort: (h) => setSortConfig(prev => ({ key: h, direction: prev.key === h && prev.direction === "asc" ? "desc" : "asc" })),
+ onSort: (key) => {
+ if (sortConfig.key === key) {
+ setSortConfig({ key, direction: sortConfig.direction === "asc" ? "desc" : "asc" });
+ } else {
+ setSortConfig({ key, direction: "asc" });
+ }
+ },
onExpandToggle: (id) => setExpandedId(expandedId === id ? null : id),
onSelectJob: setSelectedJobId,
- }),
+ })
+ );
+}
- // Sync toast
- syncToast && React.createElement("div", {
- style: {
- position: "fixed",
- bottom: "1.5rem",
- left: "50%",
- transform: "translateX(-50%)",
- background: "var(--background)",
- color: "var(--foreground-base, var(--foreground))",
- border: "1px solid var(--foreground-base, var(--foreground))",
- borderRadius: "0.5rem",
- padding: "0.6rem 1.25rem",
- fontSize: "0.85rem",
- fontFamily: "var(--theme-font-mono, monospace)",
- zIndex: 10000,
- boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
- whiteSpace: "nowrap",
- }
- }, syncToast.msg),
+// ── Sub-components for modals ──────────────────────────────────────────
+
+function PaceExplainer({ s, windowLabel, t }) {
+ return React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem", textTransform: "none" } },
+ t("pace.what_this_means", "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.how_its_calculated", "How it's calculated")),
+ React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6, textTransform: "none" } },
+ React.createElement("div", null, t("pace.nominal_formula", "Nominal = scheduled runs \u00d7 average cost per run")),
+ React.createElement("div", null, t("pace.trend_formula", "Trend = actual runs \u00d7 average cost per run")),
+ React.createElement("div", null, t("pace.pace_formula", "Pace = Trend / Nominal")),
+ React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6, textTransform: "none" } }, t("shared.all_scaled_30d", "All scaled to a 30\u2011day month using the selected window."))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.color_guide", "Color guide")),
+ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "0.35rem", fontSize: "0.78rem", marginBottom: "0.75rem", textTransform: "none" } },
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#4ade80" } }),
+ React.createElement("span", null, t("shared.green_under_budget", "Green (< 1.0\u00d7) \u2014 Under budget. Spending less than scheduled."))
+ ),
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "var(--foreground)" } }),
+ React.createElement("span", null, t("shared.neutral_budget", "Neutral (1.0\u20132.0\u00d7) \u2014 On track. Slight variance within normal range."))
+ ),
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("span", { style: { display: "inline-block", width: "0.6rem", height: "0.6rem", borderRadius: "50%", background: "#ef4444" } }),
+ React.createElement("span", null, t("shared.red_over_budget", "Red (\u2265 2.0\u00d7) \u2014 Over budget. Actual spend is double (or more) the nominal rate."))
+ )
+ )
+ );
+}
+
+function RunsExplainer({ windowLabel, t }) {
+ return React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("runs.what_this_means", "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries.")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.trend_calculation", "Trend calculation")),
+ React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("runs.trend_formula", "Trend % = ((current runs \u2212 prior runs) / prior runs) \u00d7 100")),
+ React.createElement("div", { style: { marginTop: "0.25rem", opacity: 0.6 } }, t("runs.trend_note", "Positive = more runs than the prior window. Negative = fewer runs."))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.window_context", "Window context")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
+ t("shared.showing_window", "Showing "), React.createElement("strong", null, windowLabel), ". " + t("shared.prior_window_note", "The prior comparison window is the same duration shifted back in time.")
+ )
+ );
+}
+
+function CostExplainer({ windowLabel, t }) {
+ return React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("cost.what_this_means", "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity.")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.trend_calculation", "Trend calculation")),
+ React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("cost.trend_formula", "Trend % = ((current cost \u2212 prior cost) / prior cost) \u00d7 100"))
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.window_context", "Window context")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85 } },
+ t("shared.showing_window", "Showing "), React.createElement("strong", null, windowLabel), ". " + t("shared.prior_window_note", "The prior comparison window is the same duration shifted back in time.")
+ )
+ );
+}
+
+function TokensExplainer({ s, t }) {
+ return React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.what_this_means", "What this means")),
+ React.createElement("p", { style: { fontSize: "0.8rem", lineHeight: 1.5, opacity: 0.85, marginBottom: "0.75rem" } },
+ t("tokens.what_this_means", "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper).")
+ ),
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.breakdown", "Breakdown")),
+ React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", marginBottom: "0.75rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("summary.in", "Input") + ": " + fmtCompact(s.total_input_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_input_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
+ React.createElement("div", null, t("summary.out", "Output") + ": " + fmtCompact(s.total_output_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_output_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)"),
+ React.createElement("div", null, t("summary.cached", "Cached") + ": " + fmtCompact(s.total_cache_read_tokens) + " (" + ((s.total_tokens || 1) > 0 ? ((s.total_cache_read_tokens || 0) / s.total_tokens * 100).toFixed(1) : "0") + "%)")
+ )
+ );
+}
+
+function JobDetailsBlock({ j, t }) {
+ return React.createElement("div", { style: { borderTop: "1px solid var(--color-border)", paddingTop: "1rem" } },
+ React.createElement("h3", { style: { fontSize: "0.85rem", fontWeight: 800, marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.05em", opacity: 0.85 } }, t("shared.job_details", "Job details")),
+ React.createElement("div", { style: { fontSize: "0.78rem", background: "rgba(255,255,255,0.03)", padding: "0.6rem 0.75rem", borderRadius: "0.35rem", lineHeight: 1.6 } },
+ React.createElement("div", null, t("job_breakdown.schedule", "Schedule") + ": " + ((j.schedule && j.schedule.display) || "\u2014")),
+ React.createElement("div", null, t("job_breakdown.last_run", "Last run") + ": " + fmtTime(j.last_run)),
+ React.createElement("div", null, t("model_breakdown.model", "Model") + ": " + (j.last_model || "\u2014")),
+ React.createElement("div", null, t("job_breakdown.avg_time", "Avg Duration") + ": " + (j.avg_duration != null ? fmtDuration(j.avg_duration) : "\u2014"))
+ )
);
}
diff --git a/dashboard/src/components/DaySelector.js b/dashboard/src/components/DaySelector.js
index af30a3f..0c2e2b8 100644
--- a/dashboard/src/components/DaySelector.js
+++ b/dashboard/src/components/DaySelector.js
@@ -1,5 +1,6 @@
import { React, useState } from "../lib/sdk.js";
import { Button } from "../lib/sdk.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
const PRESETS = [
{ label: "7D", value: 7 },
@@ -10,6 +11,7 @@ const PRESETS = [
const MAX_DAYS = 365;
export function DaySelector({ selected, onChange, label = null }) {
+ const t = useCronalyticsI18n();
const [custom, setCustom] = useState("");
const applyCustom = () => {
@@ -79,9 +81,9 @@ export function DaySelector({ selected, onChange, label = null }) {
size: "sm",
outlined: true,
onClick: applyCustom,
- title: "Apply custom days",
+ title: t("day_selector.apply_custom", "Apply custom days"),
},
- "Go"
+ t("day_selector.go", "Go")
)
),
];
diff --git a/dashboard/src/components/ErrorBoundary.js b/dashboard/src/components/ErrorBoundary.js
index 0ce8f4b..cd8e73d 100644
--- a/dashboard/src/components/ErrorBoundary.js
+++ b/dashboard/src/components/ErrorBoundary.js
@@ -1,9 +1,5 @@
import { React } from "../lib/sdk.js";
-/**
- * Error Boundary to prevent a single component crash from white-screening
- * the entire Cronalytics plugin tab.
- */
export class PluginErrorBoundary extends React.Component {
constructor(props) {
super(props);
@@ -16,26 +12,16 @@ export class PluginErrorBoundary extends React.Component {
render() {
if (this.state.hasError) {
- return React.createElement(
- "div",
- {
- style: {
- padding: "2rem",
- color: "var(--color-destructive, #ef4444)",
- textAlign: "center",
- fontFamily: "var(--theme-font-mono, monospace)",
- },
- },
- React.createElement(
- "div",
- { style: { fontWeight: 700, marginBottom: "0.5rem" } },
- "Cronalytics Error"
- ),
- React.createElement(
- "div",
- { style: { fontSize: "0.85rem", opacity: 0.8 } },
- "Something went wrong. Please refresh or contact support."
- )
+ return React.createElement("div", {
+ style: {
+ padding: "2rem",
+ textAlign: "center",
+ fontFamily: "var(--theme-font-mono, monospace)",
+ color: "var(--foreground)",
+ }
+ },
+ React.createElement("h3", { style: { marginBottom: "0.5rem" } }, "Cronalytics Error"),
+ React.createElement("p", { style: { opacity: 0.7 } }, "Something went wrong. Please refresh or contact support.")
);
}
return this.props.children;
diff --git a/dashboard/src/components/HeroBanner.js b/dashboard/src/components/HeroBanner.js
index 529ab42..fd5ccd9 100644
--- a/dashboard/src/components/HeroBanner.js
+++ b/dashboard/src/components/HeroBanner.js
@@ -1,6 +1,8 @@
import { React, useState } from "../lib/sdk.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function HeroBanner() {
+ const t = useCronalyticsI18n();
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem("cronalytics:hero:collapsed") === "1"; } catch { return false; }
});
@@ -25,11 +27,11 @@ export function HeroBanner() {
cursor: "pointer",
},
onClick: toggle,
- title: "Expand hero banner",
+ title: t("hero.expand_tooltip", "Expand hero banner"),
},
React.createElement("div", { style: { display: "flex", alignItems: "baseline", gap: "0.5rem" } },
- React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", fontWeight: 700, opacity: 0.8, letterSpacing: "0.08em", textTransform: "uppercase" } }, "CRONALYTICS"),
- React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.65rem", opacity: 0.5, letterSpacing: "0.1em", textTransform: "uppercase" } }, "Observe. Measure. Optimize.")
+ React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", fontWeight: 700, opacity: 0.8, letterSpacing: "0.08em", textTransform: "uppercase" } }, t("hero.title", "CRONALYTICS")),
+ React.createElement("span", { style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.65rem", opacity: 0.5, letterSpacing: "0.1em", textTransform: "uppercase" } }, t("hero.tagline", "Observe. Measure. Optimize."))
),
React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.5 } }, "▼")
);
@@ -48,7 +50,7 @@ export function HeroBanner() {
// Collapse toggle
React.createElement("button", {
onClick: toggle,
- title: "Collapse hero banner",
+ title: t("hero.collapse_tooltip", "Collapse hero banner"),
style: {
position: "absolute",
top: 4,
@@ -71,8 +73,8 @@ export function HeroBanner() {
opacity: 0.6,
marginBottom: "0.15rem"
}
- }, "/ˈkrɒn.əˌlɪt.ɪks/",
- React.createElement("i", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.65rem" } }, "(noun)")
+ }, t("hero.pronunciation", "/ˈkrɒn.əˌlɪt.ɪks/"),
+ React.createElement("i", { style: { opacity: 0.5, marginLeft: "0.5rem", fontSize: "0.65rem" } }, t("hero.noun", "(noun)"))
),
React.createElement("div", {
style: {
@@ -83,7 +85,7 @@ export function HeroBanner() {
maxWidth: "42rem",
marginBottom: "0.15rem"
}
- }, "1. Cron analytics and observability."),
+ }, t("hero.definition_1", "1. Cron analytics and observability.")),
React.createElement("div", {
style: {
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
@@ -93,7 +95,7 @@ export function HeroBanner() {
maxWidth: "42rem",
marginBottom: "0.35rem"
}
- }, "2. The dashboard for agentic automations in Hermes."),
+ }, t("hero.definition_2", "2. The dashboard for agentic automations in Hermes.")),
React.createElement("div", {
style: {
fontFamily: "var(--theme-font-mono, monospace)",
@@ -103,6 +105,6 @@ export function HeroBanner() {
textTransform: "uppercase",
opacity: 0.6
}
- }, "Observe. Measure. Optimize.")
+ }, t("hero.tagline", "Observe. Measure. Optimize."))
);
}
diff --git a/dashboard/src/components/JobBreakdown.js b/dashboard/src/components/JobBreakdown.js
index 4facde0..09e8547 100644
--- a/dashboard/src/components/JobBreakdown.js
+++ b/dashboard/src/components/JobBreakdown.js
@@ -1,18 +1,31 @@
import { React, Card, CardHeader, CardTitle, CardContent, Badge, Button } from "../lib/sdk.js";
import { fmtCost, fmtCompact, fmtDuration, fmtTime, fmtRel, fmtSyncAge, paceColor, paceBg } from "../lib/formatters.js";
import { ClockIcon, RefreshCwIcon } from "../lib/icons.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function JobBreakdown({
jobList, sortedJobs, sortConfig, expandedId,
syncing, syncInfo, days, windowLabel,
onSync, onSort, onExpandToggle, onSelectJob
}) {
+ const t = useCronalyticsI18n();
+ const HEADERS = [
+ t("job_breakdown.job", "Job"),
+ t("job_breakdown.runs", "Runs"),
+ t("job_breakdown.avg_time", "Avg Duration"),
+ t("job_breakdown.est_cost", "Est Cost"),
+ t("job_breakdown.avg_est_cost", "Avg Est Cost"),
+ t("job_breakdown.nominal_mo", "Nominal/mo"),
+ t("job_breakdown.trend_mo", "Trend/mo"),
+ t("job_breakdown.pace", "Pace"),
+ ];
+
return React.createElement(Card, { style: { marginBottom: "1.5rem" } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", width: "100%" } },
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem" } },
ClockIcon(16),
- React.createElement(CardTitle, null, "Jobs Breakdown")
+ React.createElement(CardTitle, null, t("job_breakdown.title", "Jobs Breakdown"))
),
React.createElement("div", { style: { display: "flex", gap: "0.75rem", alignItems: "center" } },
React.createElement(Button, {
@@ -23,9 +36,9 @@ export function JobBreakdown({
}, syncing
? React.createElement("span", { style: { display: "inline-flex", alignItems: "center", gap: "0.35rem" } },
RefreshCwIcon(14, { style: { animation: "cronalytics-spin 1s linear infinite" } }),
- "Syncing"
+ t("shared.loading", "Syncing")
)
- : "Sync Now"
+ : t("shared.sync_now", "Sync Now")
),
syncInfo && syncInfo.lastSync && (() => {
const age = fmtSyncAge(syncInfo.lastSync);
@@ -45,24 +58,25 @@ export function JobBreakdown({
jobList.length === 0
? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } },
syncing
- ? "Syncing cron sessions..."
+ ? t("shared.loading", "Syncing cron sessions...")
: (syncInfo && syncInfo.lastSync
- ? "No jobs in " + windowLabel.toLowerCase() + ". Last sync: " + syncInfo.lastSync.split("T").join(" ").slice(0, 19) + " UTC"
- : "No cron jobs captured. Click Sync Now to backfill from state.db.")
+ ? t("job_breakdown.no_jobs_window", "No jobs in {window}. Last sync: {time} UTC", { window: windowLabel.toLowerCase(), time: syncInfo.lastSync.split("T").join(" ").slice(0, 19) })
+ : t("job_breakdown.no_jobs_sync", "No cron jobs captured. Click Sync Now to backfill from state.db."))
)
: React.createElement("div", { style: { overflow: "auto" } },
React.createElement("table", { style: { width: "100%", borderCollapse: "collapse", fontSize: "0.78rem" } },
React.createElement("thead", null,
React.createElement("tr", { style: { borderBottom: "1px solid var(--color-border)" } },
- ["Job", "Runs", "Avg Time", "Total Cost", "Avg Cost", "Nominal/mo", "Trend/mo", "Pace"].map(h => {
+ HEADERS.map(h => {
+
const isActive = sortConfig.key === h;
return React.createElement("th", {
key: h,
tabIndex: 0,
role: "button",
"aria-label": isActive
- ? "Sorted by " + h + ", " + (sortConfig.direction === "asc" ? "ascending" : "descending")
- : "Sort by " + h,
+ ? t("job_breakdown.sorted_by", "Sorted by {col}, {dir}", { col: h, dir: sortConfig.direction === "asc" ? t("job_breakdown.ascending", "ascending") : t("job_breakdown.descending", "descending") })
+ : t("job_breakdown.sort_by", "Sort by {col}", { col: h }),
onClick: () => onSort(h),
onKeyDown: (e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -71,7 +85,7 @@ export function JobBreakdown({
}
},
style: {
- textAlign: h === "Job" ? "left" : "right",
+ textAlign: h === HEADERS[0] ? "left" : "right",
padding: "0.5rem 0.35rem",
cursor: "pointer",
fontFamily: "var(--theme-font-mono, monospace)",
@@ -79,7 +93,7 @@ export function JobBreakdown({
userSelect: "none",
borderBottom: "2px solid var(--color-border)",
},
- title: h === "Pace" ? "Pace = Trend \u00f7 Nominal. Under 1.0\u00d7 = under budget. Over 2.0\u00d7 = over budget." : undefined
+ title: h === t("job_breakdown.pace", "Pace") ? "Pace = Trend \u00f7 Nominal. Under 1.0\u00d7 = under budget. Over 2.0\u00d7 = over budget." : undefined
}, h + (isActive ? (sortConfig.direction === "asc" ? " \u2191" : " \u2193") : ""));
})
)
@@ -99,13 +113,13 @@ export function JobBreakdown({
j.job_mode === "no_agent" && React.createElement(Badge, {
size: "xs",
style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 }
- }, "No agent")
+ }, t("job_breakdown.mode_no_agent", "No agent"))
)
),
React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, (j.runs || 0).toLocaleString()),
React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtDuration(j.avg_duration)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.total_cost)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.avg_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.tot_estimated_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } }, fmtCost(j.avg_estimated_cost)),
React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem" } },
j.projections && j.projections.projected_cost_30d != null
? fmtCost(j.projections.projected_cost_30d) + "/mo"
@@ -140,10 +154,10 @@ export function JobBreakdown({
React.createElement("div", {
style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.72rem" }
},
- "Tokens: " + fmtCompact(j.total_tokens) + " total "
- + "(" + fmtCompact(j.total_input_tokens) + " in / "
- + fmtCompact(j.total_output_tokens) + " out / "
- + fmtCompact(j.total_cache_read_tokens) + " cached)"
+ t("summary.tokens", "Tokens") + ": " + fmtCompact(j.total_tokens) + " total "
+ + "(" + fmtCompact(j.total_input_tokens) + " " + t("summary.in", "in") + " / "
+ + fmtCompact(j.total_output_tokens) + " " + t("summary.out", "out") + " / "
+ + fmtCompact(j.total_cache_read_tokens) + " " + t("summary.cached", "cached") + ")"
),
React.createElement("div", {
style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.72rem" }
@@ -156,11 +170,11 @@ export function JobBreakdown({
React.createElement("div", {
style: { fontFamily: "var(--theme-font-mono, monospace)", fontSize: "0.7rem", opacity: 0.7, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", display: "flex", alignItems: "center", gap: "0.5rem" }
},
- (j.projections && j.projections.schedule_display ? j.projections.schedule_display : "No schedule"),
- " Last: ", fmtTime(j.last_run),
- j.last_model ? " using " + j.last_model : "",
- " Next: ", j.projections && j.projections.next_run_at ? fmtRel(j.projections.next_run_at) : "\u2014",
- j.job_mode === "no_agent" && React.createElement("span", { style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.5, marginLeft: "0.25rem" } }, "[No agent]")
+ (j.projections && j.projections.schedule_display ? j.projections.schedule_display : t("job_breakdown.no_schedule", "No schedule")),
+ " " + t("job_breakdown.last", "Last") + ": ", fmtTime(j.last_run),
+ j.last_model ? " " + t("job_breakdown.using", "using") + " " + j.last_model : "",
+ " " + t("job_breakdown.next", "Next") + ": ", j.projections && j.projections.next_run_at ? fmtRel(j.projections.next_run_at) : "\u2014",
+ j.job_mode === "no_agent" && React.createElement("span", { style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.5, marginLeft: "0.25rem" } }, "[" + t("job_breakdown.mode_no_agent", "No agent") + "]")
),
React.createElement("button", {
type: "button",
@@ -179,7 +193,7 @@ export function JobBreakdown({
},
onMouseEnter: (e) => { e.currentTarget.style.background = "rgba(255,255,255,0.15)"; },
onMouseLeave: (e) => { e.currentTarget.style.background = "rgba(255,255,255,0.08)"; },
- }, "See Runs")
+ }, t("job_breakdown.see_runs", "See Runs"))
)
)
)
diff --git a/dashboard/src/components/JobDetailView.js b/dashboard/src/components/JobDetailView.js
index 16af7a4..850193f 100644
--- a/dashboard/src/components/JobDetailView.js
+++ b/dashboard/src/components/JobDetailView.js
@@ -2,26 +2,28 @@ import { React, useState } from "../lib/sdk.js";
import { Badge } from "../lib/sdk.js";
import { useApi } from "../hooks/useApi.js";
import { fmtTime, fmtCost, fmtCompact, fmtDuration } from "../lib/formatters.js";
-
-const COLUMNS = [
- { label: "Time", key: "run_time", align: "left" },
- { label: "Cost", key: "estimated_cost_usd", align: "right" },
- { label: "Duration", key: "duration_seconds", align: "right" },
- { label: "Tokens", key: "input_tokens", align: "right" },
- { label: "Model", key: "model", align: "left" },
- { label: "Mode", key: "job_mode", align: "center" },
- { label: "Result", key: "success", align: "center" },
-];
-
-function tokTotal(r) {
- return (r.input_tokens || 0) + (r.output_tokens || 0) + (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
-}
+import { useCronalyticsI18n } from "../i18n/index.js";
export function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir }) {
+ const t = useCronalyticsI18n();
const [sKey, setSKey] = useState(sortKey);
const [sDir, setSDir] = useState(sortDir);
- const path = `/api/plugins/cronalytics/jobs/${encodeURIComponent(jobId)}/runs?days=${days}&outcome=${outcome}&sort_key=${sKey}&sort_dir=${sDir}&limit=200`;
+ const COLUMNS = [
+ { label: t("job_detail.time", "Time"), key: "run_time", align: "left", width: "10rem" },
+ { label: t("job_detail.est_cost", "Est Cost"), key: "estimated_cost", align: "right", width: "6rem" },
+ { label: t("job_detail.duration", "Duration"), key: "duration_seconds", align: "right", width: "5rem" },
+ { label: t("summary.tokens", "Tokens"), key: "input_tokens", align: "right", width: "6rem" },
+ { label: t("model_breakdown.model", "Model"), key: "model", align: "left", width: "auto" },
+ { label: t("job_detail.mode", "Mode"), key: "job_mode", align: "center", width: "4rem" },
+ { label: t("job_detail.result", "Result"), key: "success", align: "center", width: "3.5rem" },
+ ];
+
+ function tokTotal(r) {
+ return (r.input_tokens || 0) + (r.output_tokens || 0) + (r.cache_read_tokens || 0) + (r.cache_write_tokens || 0);
+ }
+
+ const path = `/api/plugins/cronalytics/jobs/${encodeURIComponent(jobId)}/runs?days=${days}&outcome=${outcome}&sort_key=${sKey}&sort_dir=${sDir}&limit=250`;
const runs = useApi(path);
const sortedRuns = runs.data && runs.data.runs
@@ -29,7 +31,7 @@ export function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir
const dir = sDir === "desc" ? -1 : 1;
const av = a[sKey], bv = b[sKey];
if (sKey === "input_tokens") return dir * (tokTotal(a) - tokTotal(b));
- if (sKey === "run_time" || sKey === "estimated_cost_usd" || sKey === "duration_seconds") return dir * (av - bv);
+ if (sKey === "run_time" || sKey === "estimated_cost" || sKey === "duration_seconds") return dir * (av - bv);
if (sKey === "success") return dir * ((av ? 1 : 0) - (bv ? 1 : 0));
if (av == null || av === "") return 1;
if (bv == null || bv === "") return -1;
@@ -78,17 +80,17 @@ export function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir
fontFamily: "var(--theme-font-mono, monospace)",
},
},
- runs.data && runs.data.runs ? runs.data.runs.length + " run" + (runs.data.runs.length === 1 ? "" : "s") : ""
+ runs.data && runs.data.runs ? runs.data.runs.length + " " + t("job_detail.run", "run") + (runs.data.runs.length === 1 ? "" : "s") : ""
)
)
),
runs.loading
- ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, "Loading runs...")
+ ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, t("job_detail.loading", "Loading runs..."))
: runs.error
- ? React.createElement("div", { style: { color: "#ef4444", padding: "1rem 0" } }, "Error: " + runs.error)
+ ? React.createElement("div", { style: { color: "#ef4444", padding: "1rem 0" } }, t("job_detail.error_prefix", "Error: ") + runs.error)
: !sortedRuns.length
- ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, "No runs captured for this job.")
+ ? React.createElement("div", { style: { opacity: 0.6, padding: "1rem 0" } }, t("job_detail.no_runs", "No runs captured for this job."))
: React.createElement(
React.Fragment,
null,
@@ -127,9 +129,16 @@ export function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir
cursor: "pointer",
userSelect: "none",
whiteSpace: "nowrap",
+ width: col.width || "auto",
},
},
- col.label + (isActive ? (sDir === "desc" ? " ↓" : " ↑") : "")
+ [
+ col.label,
+ React.createElement("span", {
+ key: "arrow",
+ style: { display: "inline-block", width: "1em", marginLeft: "0.15rem", textAlign: "center" }
+ }, isActive ? (sDir === "desc" ? "\u2193" : "\u2191") : "")
+ ]
);
})
)
@@ -158,37 +167,61 @@ export function JobDetailView({ jobId, jobName, days, outcome, sortKey, sortDir
key: r.session_id,
style: { borderBottom: "1px solid rgba(255,255,255,0.04)" },
},
- React.createElement("td", { style: { padding: "0.4rem 0.35rem", whiteSpace: "nowrap" } }, fmtTime(r.run_time)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtCost(r.estimated_cost_usd)),
- React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtDuration(r.duration_seconds)),
+ React.createElement("td", { style: { padding: "0.4rem 0.35rem", whiteSpace: "nowrap", width: "10rem" } }, fmtTime(r.run_time)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 1.85rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", width: "6rem" } }, fmtCost(r.estimated_cost)),
+ React.createElement("td", { style: { textAlign: "right", padding: "0.4rem 1.35rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", width: "5rem" } }, fmtDuration(r.duration_seconds)),
React.createElement(
"td",
- { style: { textAlign: "right", padding: "0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", whiteSpace: "nowrap" } },
+ { style: { textAlign: "right", padding: "0.4rem 1.35rem 0.4rem 0.35rem", fontFamily: "var(--theme-font-mono, monospace)", whiteSpace: "nowrap", width: "6rem" } },
(() => {
const total = tokTotal(r);
- if (total === 0) return "—";
+ if (total === 0) return "\u2014";
return fmtCompact(total);
})()
),
- React.createElement("td", { style: { padding: "0.4rem 0.35rem" } }, r.model || "—"),
+ React.createElement("td", { style: { padding: "0.4rem 0.35rem", overflow: "hidden", textOverflow: "ellipsis", width: "auto" } }, r.model || "\u2014"),
React.createElement(
"td",
- { style: { textAlign: "center", padding: "0.4rem 0.35rem" } },
+ { style: { textAlign: "center", padding: "0.4rem 0.35rem", width: "4rem" } },
r.job_mode === "no_agent"
- ? React.createElement(Badge, { size: "xs", style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 } }, "No agent")
- : React.createElement("span", { style: { fontSize: "0.65rem", opacity: 0.45 } }, "Agent")
+ ? React.createElement(Badge, { size: "xs", style: { fontSize: "0.6rem", textTransform: "uppercase", opacity: 0.7 } }, t("job_breakdown.mode_no_agent", "No agent"))
+ : React.createElement("span", { style: { fontSize: "0.65rem", opacity: 0.45 } }, t("mode_toggle.agent", "Agent"))
),
React.createElement(
"td",
- { style: { textAlign: "center", padding: "0.4rem 0.35rem" } },
+ { style: { textAlign: "center", padding: "0.4rem 0.35rem", width: "3.5rem" } },
r.success
- ? React.createElement("span", { style: { color: "#22c55e" } }, "✓")
- : React.createElement("span", { style: { color: "#ef4444" } }, "✗")
+ ? React.createElement("span", { style: { color: "#22c55e" } }, "\u2713")
+ : React.createElement("span", { style: { color: "#ef4444" } }, "\u2717")
)
)
)
)
)
+ ),
+ runs.data && runs.data.more_available && React.createElement(
+ "div",
+ {
+ style: {
+ marginTop: "0.75rem",
+ padding: "0.5rem 0.75rem",
+ fontSize: "0.72rem",
+ opacity: 0.7,
+ background: "rgba(255,255,255,0.03)",
+ borderRadius: "0.35rem",
+ lineHeight: 1.5,
+ },
+ },
+ t("job_detail.showing", "Showing "),
+ runs.data.runs.length,
+ t("job_detail.of", " of "),
+ runs.data.total_runs.toLocaleString(),
+ " ", t("job_detail.runs_plural", "runs"), ". ",
+ t("job_detail.use_cli", "Use "),
+ React.createElement("code", { style: { fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.9 } },
+ "cronalytics runs --job " + jobId + " --days " + (days === 0 ? "0" : days)
+ ),
+ t("job_detail.for_full_history", " for full history.")
)
)
);
diff --git a/dashboard/src/components/LeaderBoard.js b/dashboard/src/components/LeaderBoard.js
index 2686a42..a038907 100644
--- a/dashboard/src/components/LeaderBoard.js
+++ b/dashboard/src/components/LeaderBoard.js
@@ -1,11 +1,13 @@
import { React, Card, CardHeader, CardTitle, CardContent } from "../lib/sdk.js";
import { fmtCost, fmtCompact, paceColor } from "../lib/formatters.js";
import { ZapIcon, BanknoteIcon, BlocksIcon, MetronomeIcon, InfoIcon } from "../lib/icons.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopTokensClick, onTopPaceClick }) {
+ const t = useCronalyticsI18n();
// Precompute totals for "% of total" context on leader cards
const totalRuns = jobList.reduce((sum, j) => sum + (j.runs || 0), 0);
- const totalCost = jobList.reduce((sum, j) => sum + (j.total_cost || 0), 0);
+ const totalCost = jobList.reduce((sum, j) => sum + (j.tot_estimated_cost || 0), 0);
const totalTokens = jobList.reduce((sum, j) => sum + (j.total_tokens || 0), 0);
const cardHover = {
@@ -36,12 +38,12 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
(() => {
const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.runs || 0) > (a.runs || 0) ? b : a, jobList[0]) : null;
const label = j ? (j.name || j.job_id) : "\u2014";
- return React.createElement("div", cardProps(onTopRunsClick, "Top Runs details", { minWidth: 0, overflow: "hidden" }),
+ return React.createElement("div", cardProps(onTopRunsClick, t("leaderboard.top_runs", "Top Runs") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, ZapIcon(14)),
- React.createElement(CardTitle, null, "Top Runs"),
+ React.createElement(CardTitle, null, t("leaderboard.top_runs", "Top Runs")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -54,7 +56,7 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
title: label
}, label),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalRuns > 0 ? (Math.round(((j.runs || 0) / totalRuns) * 100)) + "% of total runs" : ""
+ totalRuns > 0 ? (Math.round(((j.runs || 0) / totalRuns) * 100)) + "% " + t("leaderboard.of_total_runs", "% of total runs") : ""
)
)
)
@@ -62,27 +64,30 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
})(),
// Top Cost
(() => {
- const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.total_cost || 0) > (a.total_cost || 0) ? b : a, jobList[0]) : null;
+ const j = jobList.length > 0 ? jobList.reduce((a, b) => (b.tot_estimated_cost || 0) > (a.tot_estimated_cost || 0) ? b : a, jobList[0]) : null;
const label = j ? (j.name || j.job_id) : "\u2014";
- return React.createElement("div", cardProps(onTopCostClick, "Top Cost details", { minWidth: 0, overflow: "hidden" }),
+ return React.createElement("div", cardProps(onTopCostClick, t("leaderboard.top_est_cost", "Top Cost") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, BanknoteIcon(14)),
- React.createElement(CardTitle, null, "Top Cost"),
+ React.createElement(CardTitle, null, t("leaderboard.top_est_cost", "Top Cost")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
React.createElement(CardContent, null,
- React.createElement("div", {
- style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", lineHeight: 1.15, color: "#f5a623", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }
- }, j ? fmtCost(j.total_cost) : "\u2014"),
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("div", {
+ style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: "#f5a623" }
+ }, j ? fmtCost(j.tot_estimated_cost) : "\u2014"),
+ j && React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.95, fontFamily: "var(--theme-font-mono, monospace)", background: "rgba(245,166,35,0.12)", border: "1px solid rgba(245,166,35,0.25)", borderRadius: "0.25rem", padding: "0.05rem 0.4rem" } }, t("summary.estimated", "Estimated"))
+ ),
React.createElement("div", {
style: { fontSize: "0.75rem", fontWeight: 600, fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.2rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
title: label
}, label),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalCost > 0 ? (Math.round(((j.total_cost || 0) / totalCost) * 100)) + "% of total cost" : ""
+ totalCost > 0 ? (Math.round(((j.tot_estimated_cost || 0) / totalCost) * 100)) + "% " + t("leaderboard.of_total_est_cost", "% of total est cost") : ""
)
)
)
@@ -92,12 +97,12 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
(() => {
const j = jobList.length > 0 ? jobList.reduce((a, b) => ((b.total_tokens || 0) > (a.total_tokens || 0) ? b : a), jobList[0]) : null;
const label = j ? (j.name || j.job_id) : "\u2014";
- return React.createElement("div", cardProps(onTopTokensClick, "Top Tokens details", { minWidth: 0, overflow: "hidden" }),
+ return React.createElement("div", cardProps(onTopTokensClick, t("leaderboard.top_tokens", "Top Tokens") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, BlocksIcon(14)),
- React.createElement(CardTitle, null, "Top Tokens"),
+ React.createElement(CardTitle, null, t("leaderboard.top_tokens", "Top Tokens")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -110,7 +115,7 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
title: label
}, label),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.6, marginTop: "0.15rem" } },
- totalTokens > 0 ? (Math.round(((j.total_tokens || 0) / totalTokens) * 100)) + "% of total tokens" : ""
+ totalTokens > 0 ? (Math.round(((j.total_tokens || 0) / totalTokens) * 100)) + "% " + t("leaderboard.of_total_tokens", "% of total tokens") : ""
)
)
)
@@ -127,12 +132,12 @@ export function LeaderBoard({ jobList, onTopRunsClick, onTopCostClick, onTopToke
: null;
const label = j ? (j.name || j.job_id) : "\u2014";
const p = j && j.projections && j.projections.pace != null ? j.projections.pace : null;
- return React.createElement("div", cardProps(onTopPaceClick, "Top Pace details", { minWidth: 0, overflow: "hidden" }),
+ return React.createElement("div", cardProps(onTopPaceClick, t("leaderboard.most_efficient", "Top Pace") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { color: "#ff5722", lineHeight: 0 } }, MetronomeIcon(14)),
- React.createElement(CardTitle, null, "Top Pace"),
+ React.createElement(CardTitle, null, t("leaderboard.most_efficient", "Top Pace")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, InfoIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
diff --git a/dashboard/src/components/Modal.js b/dashboard/src/components/Modal.js
index 187a784..8e97b15 100644
--- a/dashboard/src/components/Modal.js
+++ b/dashboard/src/components/Modal.js
@@ -1,9 +1,11 @@
import { React, useRef, useState, useEffect } from "../lib/sdk.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
/**
* Modal overlay with Escape-to-close, backdrop click, and resize tracking.
*/
export function Modal({ isOpen, onClose, children, maxWidth }) {
+ const t = useCronalyticsI18n();
const backdropRef = useRef(null);
const [bounds, setBounds] = useState(null);
@@ -73,7 +75,7 @@ export function Modal({ isOpen, onClose, children, maxWidth }) {
"button",
{
type: "button",
- "aria-label": "Close",
+ "aria-label": t("modal.close", "Close"),
onClick: onClose,
style: {
position: "absolute",
diff --git a/dashboard/src/components/ModeToggle.js b/dashboard/src/components/ModeToggle.js
index b1e15d7..1919fb8 100644
--- a/dashboard/src/components/ModeToggle.js
+++ b/dashboard/src/components/ModeToggle.js
@@ -1,13 +1,15 @@
import { React } from "../lib/sdk.js";
import { Button } from "../lib/sdk.js";
-
-const OPTIONS = [
- { label: "All", value: "all" },
- { label: "Agent", value: "agent" },
- { label: "No Agent", value: "no_agent" },
-];
+import { useCronalyticsI18n } from "../i18n/index.js";
export function ModeToggle({ selected, onChange, label }) {
+ const t = useCronalyticsI18n();
+ const OPTIONS = [
+ { label: t("mode_toggle.all", "All"), value: "all" },
+ { label: t("mode_toggle.agent", "Agent"), value: "agent" },
+ { label: t("mode_toggle.no_agent", "No Agent"), value: "no_agent" },
+ ];
+
return React.createElement(
"div",
{ style: { display: "flex", gap: "0.5rem", alignItems: "center" } },
diff --git a/dashboard/src/components/ModelBreakdown.js b/dashboard/src/components/ModelBreakdown.js
index 69e73fc..bfdb135 100644
--- a/dashboard/src/components/ModelBreakdown.js
+++ b/dashboard/src/components/ModelBreakdown.js
@@ -1,18 +1,20 @@
import { React, Card, CardHeader, CardTitle, CardContent } from "../lib/sdk.js";
import { fmtCost } from "../lib/formatters.js";
import { CpuIcon } from "../lib/icons.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function ModelBreakdown({ costByModel }) {
+ const t = useCronalyticsI18n();
if (!costByModel || costByModel.length === 0) return null;
const topModels = costByModel.slice(0, 5);
const remaining = costByModel.length - 5;
- const maxCost = (topModels[0] && topModels[0].total_cost) || 1;
+ const maxCost = (topModels[0] && topModels[0].tot_estimated_cost) || 1;
return React.createElement(Card, { style: { marginBottom: "1.5rem" } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem" } },
CpuIcon(16),
- React.createElement(CardTitle, null, "Per-Model Breakdown")
+ React.createElement(CardTitle, null, t("model_breakdown.title", "Per-Model Breakdown"))
)
),
React.createElement(CardContent, null,
@@ -30,19 +32,19 @@ export function ModelBreakdown({ costByModel }) {
style: { flex: 1, background: "rgba(255,255,255,0.04)", height: "0.4rem", borderRadius: "0.2rem", overflow: "hidden" }
},
React.createElement("div", {
- style: { width: (Math.min(100, ((m.total_cost || 0) / maxCost) * 100)) + "%", background: "#f5a623", height: "100%", borderRadius: "0.2rem", transition: "width 0.5s ease" }
+ style: { width: (Math.min(100, ((m.tot_estimated_cost || 0) / maxCost) * 100)) + "%", background: "#f5a623", height: "100%", borderRadius: "0.2rem", transition: "width 0.5s ease" }
})
),
React.createElement("span", {
style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", flexShrink: 0, whiteSpace: "nowrap", display: "flex", alignItems: "center", gap: "0.35rem", minWidth: 0, flex: "0 0 9rem", justifyContent: "flex-end" }
},
- React.createElement("span", { style: { color: "#f5a623", width: "4.5rem", textAlign: "right", display: "inline-block" } }, fmtCost(m.total_cost)),
+ React.createElement("span", { style: { color: "#f5a623", width: "4.5rem", textAlign: "right", display: "inline-block" } }, fmtCost(m.tot_estimated_cost)),
React.createElement("span", { style: { opacity: 0.45, width: "3.5rem", textAlign: "right", display: "inline-block" } }, "\u00b7 " + (m.runs || 0).toLocaleString())
)
)),
remaining > 0 && React.createElement("div", {
style: { textAlign: "center", fontSize: "0.65rem", opacity: 0.35, marginTop: "0.3rem", fontFamily: "var(--theme-font-mono, monospace)" }
- }, "and " + remaining + " more")
+ }, t("model_breakdown.and_more", "and {n} more", { n: remaining }))
)
)
);
diff --git a/dashboard/src/components/OutcomeToggle.js b/dashboard/src/components/OutcomeToggle.js
index bbf084d..e142828 100644
--- a/dashboard/src/components/OutcomeToggle.js
+++ b/dashboard/src/components/OutcomeToggle.js
@@ -1,13 +1,15 @@
import { React } from "../lib/sdk.js";
import { Button } from "../lib/sdk.js";
-
-const OPTIONS = [
- { label: "All", value: "both" },
- { label: "Success", value: "success" },
- { label: "Failure", value: "failure" },
-];
+import { useCronalyticsI18n } from "../i18n/index.js";
export function OutcomeToggle({ selected, onChange, label }) {
+ const t = useCronalyticsI18n();
+ const OPTIONS = [
+ { label: t("outcome_toggle.all", "All"), value: "all" },
+ { label: t("outcome_toggle.success", "Success"), value: "success" },
+ { label: t("outcome_toggle.failure", "Failure"), value: "failure" },
+ ];
+
return React.createElement(
"div",
{ style: { display: "flex", gap: "0.5rem", alignItems: "center" } },
diff --git a/dashboard/src/components/SparkLine.js b/dashboard/src/components/SparkLine.js
index 94ade69..1a49a61 100644
--- a/dashboard/src/components/SparkLine.js
+++ b/dashboard/src/components/SparkLine.js
@@ -1,5 +1,6 @@
import { React, useState } from "../lib/sdk.js";
import { fmtTime, fmtCost, fmtCompact, fmtDuration } from "../lib/formatters.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
const SPARK_BAR_W = 4;
const SPARK_BAR_GAP = 1;
@@ -15,7 +16,7 @@ function _modelColor(m) {
}
function _shortModel(m) {
- if (!m) return "—";
+ if (!m) return "\u2014";
return m.split("/").pop();
}
@@ -24,11 +25,12 @@ function _tokTotal(r) {
}
export function SparkLine({ runs }) {
+ const t = useCronalyticsI18n();
const [hoverIdx, setHoverIdx] = useState(-1);
if (!runs || runs.length === 0) return null;
const chrono = [...runs].sort((a, b) => a.run_time - b.run_time);
- const maxCost = Math.max(...chrono.map(r => r.estimated_cost_usd || 0), 0.0001);
+ const maxCost = Math.max(...chrono.map(r => r.estimated_cost || 0), 0.0001);
const maxTok = Math.max(...chrono.map(_tokTotal), 1);
const maxDur = Math.max(...chrono.map(r => r.duration_seconds || 0), 0.1);
@@ -71,7 +73,7 @@ export function SparkLine({ runs }) {
}),
// Cost bars (top layer — model color)
chrono.map((r, i) => {
- const barH = ((r.estimated_cost_usd || 0) / maxCost) * h;
+ const barH = ((r.estimated_cost || 0) / maxCost) * h;
return React.createElement("rect", {
key: r.session_id,
x: i * (w + gap),
@@ -103,10 +105,10 @@ export function SparkLine({ runs }) {
React.createElement(
"span",
null,
- "— cost (bar) · ",
- React.createElement("span", { style: { color: "#60a5fa" } }, "— tokens"),
- " · ",
- React.createElement("span", { style: { color: "#fcd34d" } }, "- - duration")
+ t("sparkline.cost_bar", "\u2014 cost (bar) \u00b7 "),
+ React.createElement("span", { style: { color: "#60a5fa" } }, t("sparkline.tokens_line", "\u2014 tokens")),
+ " \u00b7 ",
+ React.createElement("span", { style: { color: "#fcd34d" } }, t("sparkline.duration_line", "- - duration"))
)
),
hoverRun &&
@@ -123,13 +125,12 @@ export function SparkLine({ runs }) {
},
},
fmtTime(hoverRun.run_time) +
- " · " +
- fmtCost(hoverRun.estimated_cost_usd) +
- " · " +
- fmtCompact(_tokTotal(hoverRun)) +
- " toks · " +
+ " \u00b7 " +
+ fmtCost(hoverRun.estimated_cost) +
+ " \u00b7 " +
+ fmtCompact(_tokTotal(hoverRun)) + " " + t("summary.tokens", "toks") + " \u00b7 " +
fmtDuration(hoverRun.duration_seconds) +
- " · " +
+ " \u00b7 " +
_shortModel(hoverRun.model)
)
);
diff --git a/dashboard/src/components/SummaryBoard.js b/dashboard/src/components/SummaryBoard.js
index 3853c13..baf2236 100644
--- a/dashboard/src/components/SummaryBoard.js
+++ b/dashboard/src/components/SummaryBoard.js
@@ -1,14 +1,16 @@
import { React, Card, CardHeader, CardTitle, CardContent } from "../lib/sdk.js";
import { fmtCost, fmtCompact, paceColor } from "../lib/formatters.js";
import { ZapIcon, BanknoteIcon, BlocksIcon, MetronomeIcon, HelpCircleIcon } from "../lib/icons.js";
+import { useCronalyticsI18n } from "../i18n/index.js";
export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick, onTokensClick, onPaceClick }) {
+ const t = useCronalyticsI18n();
const s = summary || {};
const runPct = s.previous_period && s.previous_period.runs != null && s.previous_period.runs !== 0
? ((s.total_runs - s.previous_period.runs) / s.previous_period.runs) * 100
: null;
- const costPct = s.previous_period && s.previous_period.cost != null && s.previous_period.cost !== 0
- ? ((s.total_estimated_cost - s.previous_period.cost) / s.previous_period.cost) * 100
+ const costPct = s.previous_period && s.previous_period.estimated_cost != null && s.previous_period.estimated_cost !== 0
+ ? ((s.tot_estimated_cost - s.previous_period.estimated_cost) / s.previous_period.estimated_cost) * 100
: null;
const cardHover = {
@@ -36,12 +38,12 @@ export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick,
}
},
// Job Runs
- React.createElement("div", cardProps(onRunsClick, "Job Runs details", { minWidth: 0, overflow: "hidden" }),
+ React.createElement("div", cardProps(onRunsClick, t("summary.job_runs", "Job Runs") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, ZapIcon(14)),
- React.createElement(CardTitle, null, "Job Runs"),
+ React.createElement(CardTitle, null, t("summary.job_runs", "Job Runs")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -51,50 +53,53 @@ export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick,
runPct != null ? (runPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(runPct).toFixed(0) + "%" : "\u2014"
),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.1rem" } },
- "vs prior ", days === 0 ? "period" : days + "d"
+ t("summary.vs_prior", "vs prior") + " ", days === 0 ? t("summary.period", "period") : days + "d"
)
)
)
),
// Cost
- React.createElement("div", cardProps(onCostClick, outcome === "failure" ? "Wasted cost details" : "Cost details", { minWidth: 0, overflow: "hidden" }),
+ React.createElement("div", cardProps(onCostClick, outcome === "failure" ? t("summary.estimated", "Est") + " " + t("summary.wasted", "Wasted") + " cost details" : t("summary.estimated", "Est") + " cost details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, BanknoteIcon(14)),
- React.createElement(CardTitle, null, outcome === "failure" ? "Wasted" : "Cost"),
+ React.createElement(CardTitle, null, outcome === "failure" ? t("summary.wasted", "Wasted") : t("summary.cost", "Cost")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
React.createElement(CardContent, null,
- React.createElement("div", { style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: outcome === "failure" ? "#ef4444" : "#f5a623" } },
- fmtCost(s.total_estimated_cost)
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem" } },
+ React.createElement("div", { style: { fontSize: "1.5rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: outcome === "failure" ? "#ef4444" : "#f5a623" } },
+ fmtCost(s.tot_estimated_cost)
+ ),
+ React.createElement("span", { style: { fontSize: "0.7rem", opacity: 0.95, fontFamily: "var(--theme-font-mono, monospace)", background: "rgba(245,166,35,0.12)", border: "1px solid rgba(245,166,35,0.25)", borderRadius: "0.25rem", padding: "0.05rem 0.4rem" } }, t("summary.estimated", "Estimated"))
),
- React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem", marginTop: "0.2rem", fontSize: "1.05rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: costPct != null ? (costPct > 0 ? "#ef4444" : "#4ade80") : null } },
+ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem", marginTop: "0.2rem", fontSize: "1.05rem", fontWeight: 700, fontFamily: "var(--theme-font-mono, monospace)", color: costPct != null ? (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "%" : "\u2014" } },
costPct != null ? (costPct > 0 ? "\u2191 " : "\u2193 ") + Math.abs(costPct).toFixed(0) + "%" : "\u2014"
),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.1rem" } },
- "vs prior ", days === 0 ? "period" : days + "d"
+ t("summary.vs_prior", "vs prior") + " ", days === 0 ? t("summary.period", "period") : days + "d"
),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.3rem", borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: "0.25rem" } },
- "Actual: ", s.total_actual_cost != null ? fmtCost(s.total_actual_cost) : "\u2014"
+ t("summary.actual", "Actual") + ": ", s.tot_actual_cost != null ? fmtCost(s.tot_actual_cost) : "\u2014"
),
React.createElement("div", { style: { fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)", opacity: 0.85, marginTop: "0.2rem" } },
React.createElement("span", { style: { color: "#4ade80" } }, "\u2713 ", s.success_runs || 0),
" \u00b7 ",
React.createElement("span", { style: { color: (s.failure_runs || 0) > 0 ? "#ef4444" : null } }, "\u2717 ", s.failure_runs || 0),
- (s.failure_cost != null && s.failure_cost > 0) ? " (" + fmtCost(s.failure_cost) + " wasted)" : ""
+ (s.failure_estimated_cost != null && s.failure_estimated_cost > 0) ? " (" + fmtCost(s.failure_estimated_cost) + " " + t("summary.wasted", "wasted") + ")" : ""
)
)
)
),
// Tokens
- React.createElement("div", cardProps(onTokensClick, "Tokens details", { minWidth: 0, overflow: "hidden" }),
+ React.createElement("div", cardProps(onTokensClick, t("summary.tokens", "Tokens") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, BlocksIcon(14)),
- React.createElement(CardTitle, null, "Tokens"),
+ React.createElement(CardTitle, null, t("summary.tokens", "Tokens")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -118,7 +123,7 @@ export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick,
React.createElement("span", { style: { width: "3.5rem", textAlign: "right", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtCompact(s.total_output_tokens))
),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "2.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Cached"),
+ React.createElement("span", { style: { width: "2.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.cached", "Cached")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
React.createElement("div", { style: { width: Math.min(100, ((s.total_cache_read_tokens || 0) / (s.total_tokens || 1)) * 100) + "%", background: 'var(--foreground-base, var(--foreground))', height: "100%", opacity: 0.6 } })
),
@@ -133,12 +138,12 @@ export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick,
const nominalPace = s.nominal_monthly_total || 0;
const trendPace = s.trend_monthly_total || 0;
const maxPace = Math.max(nominalPace, trendPace, 1);
- return React.createElement("div", cardProps(onPaceClick, "Pace details", { minWidth: 0, overflow: "hidden" }),
+ return React.createElement("div", cardProps(onPaceClick, t("summary.pace", "Pace") + " details", { minWidth: 0, overflow: "hidden" }),
React.createElement(Card, { style: { flex: 1 } },
React.createElement(CardHeader, null,
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.4rem", width: "100%" } },
React.createElement("span", { style: { lineHeight: 0, filter: "drop-shadow(0 0 4px rgba(255,87,34,0.55))" } }, MetronomeIcon(14)),
- React.createElement(CardTitle, null, "Pace"),
+ React.createElement(CardTitle, null, t("summary.pace", "Pace")),
React.createElement("span", { style: { marginLeft: "auto", lineHeight: 0, opacity: 0.4 } }, HelpCircleIcon({ size: 14, style: { color: "var(--foreground-base, var(--foreground))" } }))
)
),
@@ -148,14 +153,14 @@ export function SummaryBoard({ summary, days, outcome, onRunsClick, onCostClick,
}, s.pace != null ? s.pace.toFixed(2) + "\u00d7" : "\u2014"),
React.createElement("div", { style: { marginTop: "0.4rem", display: "flex", flexDirection: "column", gap: "0.2rem" } },
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "3.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Nominal"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.nominal", "Nominal")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
React.createElement("div", { style: { width: (Math.min(100, (nominalPace / maxPace) * 100)) + "%", background: 'var(--foreground-base, var(--foreground))', height: "100%", opacity: 0.6 } })
),
React.createElement("span", { style: { width: "4.5rem", textAlign: "right", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, fmtCost(nominalPace))
),
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "0.35rem" } },
- React.createElement("span", { style: { width: "3.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, "Trend"),
+ React.createElement("span", { style: { width: "4.5rem", fontSize: "0.75rem", fontFamily: "var(--theme-font-mono, monospace)" } }, t("summary.trend", "Trend")),
React.createElement("div", { style: { flex: 1, background: "rgba(255,255,255,0.04)", borderRadius: "0.15rem", height: "0.3rem", overflow: "hidden" } },
React.createElement("div", { style: { width: (Math.min(100, (trendPace / maxPace) * 100)) + "%", background: 'var(--foreground-base, var(--foreground))', height: "100%", opacity: 0.6 } })
),
diff --git a/dashboard/src/hooks/useApi.js b/dashboard/src/hooks/useApi.js
index a8e6b77..e83a760 100644
--- a/dashboard/src/hooks/useApi.js
+++ b/dashboard/src/hooks/useApi.js
@@ -16,15 +16,15 @@ import { validatorForPath } from "../lib/validate.js";
/**
* @typedef {Object} SummaryResponse
* @property {number} total_runs
- * @property {number} total_estimated_cost
- * @property {number} total_actual_cost
+ * @property {number} tot_estimated_cost
+ * @property {number} tot_actual_cost
* @property {number} total_tokens
* @property {number} total_input_tokens
* @property {number} total_output_tokens
* @property {number} [total_cache_read_tokens]
* @property {number} success_runs
* @property {number} failure_runs
- * @property {number} [failure_cost]
+ * @property {number} [failure_estimated_cost]
* @property {number} [nominal_monthly_total]
* @property {number} [trend_monthly_total]
* @property {number} [pace]
@@ -37,8 +37,8 @@ import { validatorForPath } from "../lib/validate.js";
* @property {string} job_id
* @property {string} [name]
* @property {number} runs
- * @property {number} total_cost
- * @property {number} [avg_cost]
+ * @property {number} tot_estimated_cost
+ * @property {number} [avg_estimated_cost]
* @property {number} [total_tokens]
* @property {number} [total_duration]
* @property {number} [avg_duration]
@@ -57,7 +57,7 @@ import { validatorForPath } from "../lib/validate.js";
* @typedef {Object} RunRecord
* @property {string} session_id
* @property {number} run_time
- * @property {number} estimated_cost_usd
+ * @property {number} estimated_cost
* @property {number} [duration_seconds]
* @property {boolean} [success]
* @property {string} [model]
@@ -81,7 +81,7 @@ import { validatorForPath } from "../lib/validate.js";
* @typedef {Object} ModelAggregate
* @property {string} model
* @property {number} runs
- * @property {number} total_cost
+ * @property {number} tot_estimated_cost
*/
/**
diff --git a/dashboard/src/i18n/en.js b/dashboard/src/i18n/en.js
new file mode 100644
index 0000000..9527908
--- /dev/null
+++ b/dashboard/src/i18n/en.js
@@ -0,0 +1,203 @@
+/**
+ * Cronalytics English catalog — source of truth for all user-facing strings.
+ *
+ * Organized by namespace matching component names for discoverability.
+ */
+
+import { registerCatalog } from "./index.js";
+
+registerCatalog("en", {
+ // HeroBanner — the greeting
+ hero: {
+ title: "CRONALYTICS",
+ tagline: "Observe. Measure. Optimize.",
+ pronunciation: "/ˈkrɒn.əˌlɪt.ɪks/",
+ noun: "(noun)",
+ definition_1: "1. Cron analytics and observability.",
+ definition_2: "2. The dashboard for agentic automations in Hermes.",
+ expand_tooltip: "Expand hero banner",
+ collapse_tooltip: "Collapse hero banner",
+ },
+
+ // SummaryBoard — headline stats
+ summary: {
+ job_runs: "Job Runs",
+ cost: "Cost",
+ wasted: "Wasted",
+ tokens: "Tokens",
+ cached: "Cached",
+ pace: "Pace",
+ trend: "Trend",
+ estimated: "Estimated",
+ actual: "Actual",
+ all_time: "All time",
+ last_n_days: "Last {n} days",
+ vs_prior: "vs prior",
+ period: "period",
+ nominal: "Nominal",
+ in: "In",
+ out: "Out",
+ no_schedule: "No schedule",
+ },
+
+ // LeaderBoard — top performers
+ leaderboard: {
+ title: "Leaderboard",
+ top_est_cost: "Top Cost",
+ top_runs: "Top Runs",
+ top_tokens: "Top Tokens",
+ top_duration: "Top Time",
+ most_efficient: "Top Pace",
+ of_total_est_cost: "% of total est cost",
+ of_total_runs: "% of total runs",
+ of_total_tokens: "% of total tokens",
+ },
+
+ // JobBreakdown — per-job table
+ job_breakdown: {
+ title: "Jobs Breakdown",
+ job: "Job",
+ runs: "Runs",
+ avg_time: "Avg Duration",
+ est_cost: "Est Cost",
+ avg_est_cost: "Avg Est Cost",
+ nominal_mo: "Nominal/mo",
+ trend_mo: "Trend/mo",
+ pace: "Pace",
+ mode_agent: "Agent",
+ mode_no_agent: "No agent",
+ no_schedule: "No schedule",
+ last: "Last",
+ using: "using",
+ next: "Next",
+ see_runs: "See Runs",
+ schedule: "Schedule",
+ last_run: "Last run",
+ no_jobs_window: "No jobs in {window}. Last sync: {time} UTC",
+ no_jobs_sync: "No cron jobs captured. Click Sync Now to backfill from state.db.",
+ sorted_by: "Sorted by {col}, {dir}",
+ sort_by: "Sort by {col}",
+ ascending: "ascending",
+ descending: "descending",
+ },
+
+ // JobDetailView — individual run history
+ job_detail: {
+ title_runs: "Runs",
+ mode: "Mode",
+ mode_agent: "Agent",
+ duration: "Duration",
+ est_cost: "Est Cost",
+ loading: "Loading runs...",
+ error_prefix: "Error: ",
+ for_full_history: " for full history.",
+ no_runs: "No runs found.",
+ showing: "Showing ",
+ of: " of ",
+ runs_plural: "runs",
+ use_cli: "Use ",
+ run: "run",
+ },
+
+ // ModelBreakdown — per-model stats
+ model_breakdown: {
+ title: "Per-Model Breakdown",
+ model: "Model",
+ runs: "Runs",
+ est_cost: "Est Cost",
+ and_more: "and {n} more",
+ },
+
+ // SparkLine — daily trends
+ sparkline: {
+ daily_cost: "Daily Est Cost",
+ daily_runs: "Daily Runs",
+ cost_bar: "\u2014 cost (bar) \u00b7 ",
+ tokens_line: "\u2014 tokens",
+ duration_line: "- - duration",
+ },
+
+ // DaySelector — time window picker
+ day_selector: {
+ label: "Days",
+ apply_custom: "Apply custom days",
+ go: "Go",
+ },
+
+ // ModeToggle — agent/no_agent/all filter
+ mode_toggle: {
+ label: "Mode",
+ all: "All",
+ agent: "Agent",
+ no_agent: "No Agent",
+ },
+
+ // OutcomeToggle — success/failure/all filter
+ outcome_toggle: {
+ label: "Outcomes",
+ all: "All",
+ success: "Success",
+ failure: "Failure",
+ },
+
+ // ErrorBoundary — crash handler
+ error: {
+ title: "Cronalytics Error",
+ message: "Something went wrong. Please refresh or contact support.",
+ },
+
+ // Modal — popup dialog
+ modal: {
+ close: "Close",
+ },
+
+ // Pace modal explainer
+ pace: {
+ what_this_means: "Pace compares your actual spending trend against the budget you set in your cron job definitions. It answers: \u2018At this rate, am I over or under budget?\u2019",
+ nominal_formula: "Nominal = scheduled runs \u00d7 average cost per run",
+ trend_formula: "Trend = actual runs \u00d7 average cost per run",
+ pace_formula: "Pace = Trend / Nominal",
+ },
+
+ // Runs modal explainer
+ runs: {
+ what_this_means: "Total number of cron job executions recorded in the selected window. Each run triggers your scheduled task\u2014whether it succeeds, fails, or retries.",
+ trend_formula: "Trend % = ((current runs \u2212 prior runs) / prior runs) \u00d7 100",
+ trend_note: "Positive = more runs than the prior window. Negative = fewer runs.",
+ },
+
+ // Cost modal explainer
+ cost: {
+ what_this_means: "Estimated cost is calculated from token usage and model pricing. Actual cost may differ slightly depending on provider billing granularity.",
+ trend_formula: "Trend % = ((current cost \u2212 prior cost) / prior cost) \u00d7 100",
+ },
+
+ // Tokens modal explainer
+ tokens: {
+ what_this_means: "Tokens are the currency of LLM usage. Input tokens are your prompts + context. Output tokens are the model's response. Cached tokens come from repeated prompts with identical prefixes (cheaper).",
+ },
+
+ // Shared / generic
+ shared: {
+ loading: "Loading\u2026",
+ retry: "Retry",
+ show: "Show",
+ hide: "Hide",
+ refresh: "Refresh",
+ sync_now: "Sync Now",
+ synced_n_runs: "Synced {n} runs",
+ what_this_means: "What this means",
+ how_its_calculated: "How it's calculated",
+ trend_calculation: "Trend calculation",
+ window_context: "Window context",
+ showing_window: "Showing ",
+ prior_window_note: "The prior comparison window is the same duration shifted back in time.",
+ job_details: "Job details",
+ color_guide: "Color guide",
+ neutral_budget: "Neutral (1.0\u20132.0\u00d7) \u2014 On track. Slight variance within normal range.",
+ green_under_budget: "Green (< 1.0\u00d7) \u2014 Under budget. Spending less than scheduled.",
+ red_over_budget: "Red (> 2.0\u00d7) \u2014 Over budget. Spending more than scheduled.",
+ all_scaled_30d: "All scaled to a 30\u2011day month using the selected window.",
+ breakdown: "Breakdown",
+ },
+});
diff --git a/dashboard/src/i18n/es.js b/dashboard/src/i18n/es.js
new file mode 100644
index 0000000..c008fa9
--- /dev/null
+++ b/dashboard/src/i18n/es.js
@@ -0,0 +1,183 @@
+import { registerCatalog } from "./index.js";
+
+registerCatalog("es", {
+ // cost
+ cost: {
+ trend_formula: "Tendencia % = ((costo presente − costo anterior) / costo anterior) × 100",
+ what_this_means: "El costo estimado se calcula a partir del uso de tokens y el precio del modelo. El costo real puede diferir ligeramente según la granularidad de facturación del proveedor.",
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "Aplicar días personalizados",
+ go: "Ir",
+ label: "Días",
+ },
+ // error
+ error: {
+ message: "Algo salió mal. Por favor recarga o contacta soporte.",
+ title: "Error de Cronalytics",
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "Contraer banner principal",
+ definition_1: "1. Análisis y observabilidad de cron jobs.",
+ definition_2: "2. El panel de control para automatizaciones agentivas en Hermes.",
+ expand_tooltip: "Expandir banner principal",
+ noun: "(sustantivo)",
+ pronunciation: "/ˈkrɒn.əˌlɪt.ɪks/",
+ tagline: "Observar. Medir. Optimizar.",
+ title: "CRONALYTICS",
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "ascendente",
+ avg_est_cost: "Costo est. prom.",
+ avg_time: "Dur. promedio",
+ descending: "descendente",
+ est_cost: "Costo Est.",
+ job: "Trabajo",
+ last: "Último",
+ last_run: "Última ejecución",
+ mode_agent: "Agente",
+ mode_no_agent: "Sin agente",
+ next: "Siguiente",
+ no_jobs_sync: "No se capturaron cron jobs. Haz clic en Sincronizar ahora para rellenar desde state.db.",
+ no_jobs_window: "Sin trabajos en {window}. Última sincronización: {time} UTC",
+ no_schedule: "Sin prog.",
+ nominal_mo: "Nominal/mes",
+ pace: "Ritmo",
+ runs: "Ejec.",
+ schedule: "Programación",
+ see_runs: "Ver ejecuciones",
+ sort_by: "Ordenar por {col}",
+ sorted_by: "Ordenado por {col}, {dir}",
+ title: "Desglose de trabajos",
+ trend_mo: "Tendencia/mes",
+ using: "usando",
+ },
+ // job_detail
+ job_detail: {
+ duration: "Duración",
+ error_prefix: "Error: ",
+ est_cost: "Costo Est.",
+ for_full_history: " para historial completo.",
+ loading: "Cargando ejecuciones...",
+ mode: "Modo",
+ mode_agent: "Agente",
+ no_runs: "No se encontraron ejecuciones.",
+ of: " de ",
+ result: "Resultado",
+ run: "ejecución",
+ runs_plural: "ejecuciones",
+ showing: "Mostrando ",
+ time: "Fecha",
+ title_runs: "Ejecuciones",
+ use_cli: "Usar ",
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "Mejor ritmo",
+ of_total_est_cost: "% del costo total est.",
+ of_total_runs: "% del total de ejec.",
+ of_total_tokens: "% del total de tokens",
+ title: "Tabla de líderes",
+ top_duration: "Mayor duración",
+ top_est_cost: "Mayor Costo",
+ top_runs: "Más ejecuciones",
+ top_tokens: "Más tokens",
+ },
+ // modal
+ modal: {
+ close: "Cerrar",
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "Agente",
+ all: "Todos",
+ label: "Modo",
+ no_agent: "Sin agente",
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "y {n} más",
+ est_cost: "Costo Est.",
+ model: "Modelo",
+ runs: "Ejec.",
+ title: "Desglose por modelo",
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "Todos",
+ failure: "Fallo",
+ label: "Resultados",
+ success: "Éxito",
+ },
+ // pace
+ pace: {
+ nominal_formula: "Nominal = ejecuciones programadas × costo promedio por ejecución",
+ pace_formula: "Ritmo = Tendencia / Nominal",
+ trend_formula: "Tendencia = ejecuciones reales × costo promedio por ejecución",
+ what_this_means: "El ritmo compara tu tendencia de gasto real contra el presupuesto que configuraste en tus cron jobs. Responde: ‘A este ritmo, ¿estoy por encima o por debajo del presupuesto?’",
+ },
+ // runs
+ runs: {
+ trend_formula: "Tendencia % = ((ejec. actuales − ejec. anteriores) / ejec. anteriores) × 100",
+ trend_note: "Positivo = más ejecuciones que la ventana anterior. Negativo = menos ejecuciones.",
+ what_this_means: "Número total de ejecuciones de cron jobs registradas en la ventana seleccionada. Cada ejecución activa tu tarea programada, ya sea exitosa, fallida o reintentada.",
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "Todo escalado a un mes de 30 días usando la ventana seleccionada.",
+ breakdown: "Desglose",
+ color_guide: "Guía de colores",
+ green_under_budget: "Verde (< 1.0×) — Por debajo del presupuesto. Gasto menor al programado.",
+ hide: "Ocultar",
+ how_its_calculated: "Cómo se calcula",
+ job_details: "Detalles del trabajo",
+ loading: "Cargando…",
+ neutral_budget: "Neutral (1.0–2.0×) — En camino. Variación leve dentro del rango normal.",
+ prior_window_note: "La ventana de comparación anterior tiene la misma duración desplazada en el tiempo.",
+ red_over_budget: "Rojo (> 2.0×) — Sobre presupuesto. Gasto mayor al programado.",
+ refresh: "Actualizar",
+ retry: "Reintentar",
+ show: "Mostrar",
+ showing_window: "Mostrando ",
+ sync_now: "Sincronizar ahora",
+ synced_n_runs: "Sincronizadas {n} ejecuciones",
+ trend_calculation: "Cálculo de tendencia",
+ what_this_means: "Qué significa esto",
+ window_context: "Contexto de ventana",
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "— costo (barra) · ",
+ daily_cost: "Costo Est. Diario",
+ daily_runs: "Ejecuciones diarias",
+ duration_line: "- - duración",
+ tokens_line: "— tokens",
+ },
+ // summary
+ summary: {
+ actual: "Real",
+ all_time: "Todo el tiempo",
+ cached: "En caché",
+ cost: "Costo",
+ estimated: "Estimado",
+ in: "Entrada",
+ job_runs: "Ejecuciones",
+ last_n_days: "Últimos {n} días",
+ no_schedule: "Sin programación",
+ nominal: "Nominal",
+ out: "Salida",
+ pace: "Ritmo",
+ period: "período",
+ tokens: "Tokens",
+ trend: "Tendencia",
+ vs_prior: "vs anterior",
+ wasted: "Desperdiciado",
+ },
+ // tokens
+ tokens: {
+ what_this_means: "Los tokens son la unidad de uso de los LLMs. Los tokens de entrada son tus prompts + contexto. Los tokens de salida son la respuesta del modelo. Los tokens en caché provienen de prompts repetidos con prefijos idénticos (más económicos).",
+ },
+});
\ No newline at end of file
diff --git a/dashboard/src/i18n/index.js b/dashboard/src/i18n/index.js
new file mode 100644
index 0000000..dba511a
--- /dev/null
+++ b/dashboard/src/i18n/index.js
@@ -0,0 +1,84 @@
+/**
+ * Cronalytics i18n — aligns with Hermes dashboard SDK pattern.
+ *
+ * Hermes exposes `useI18n` on the plugin SDK. We read `locale` from it
+ * and resolve our own catalog. If the host language changes, the component
+ * re-renders automatically via React context.
+ *
+ * Usage:
+ * import { useCronalyticsI18n } from "../i18n";
+ * const t = useCronalyticsI18n();
+ * return {t("summary.job_runs", "Job Runs")};
+ *
+ * Catalog shape mirrors Hermes: namespaced keys with dot separators.
+ * Example: "summary.job_runs", "leaderboard.top_cost", etc.
+ *
+ * Supports simple interpolation: t("key", "fallback", { n: 5 })
+ * or t("key", { n: 5 }) — fallback defaults to key if omitted.
+ */
+
+const CATALOGS = {};
+
+export function registerCatalog(lang, messages) {
+ CATALOGS[lang] = messages;
+}
+
+function getSDK() {
+ return window.__HERMES_PLUGIN_SDK__ || {};
+}
+
+function getLocale() {
+ const sdk = getSDK();
+ let code = "en";
+ if (sdk.useI18n) {
+ try {
+ code = sdk.useI18n().locale || "en";
+ } catch {}
+ } else {
+ code = navigator.language || "en";
+ }
+
+ // Try full match first (e.g. zh-TW, zh-CN)
+ if (CATALOGS[code]) return code;
+
+ // Fallback to base language (e.g. zh-CN -> zh)
+ const base = code.split("-")[0];
+ if (CATALOGS[base]) return base;
+
+ return "en";
+}
+
+function resolve(key, catalog) {
+ const parts = key.split(".");
+ let node = catalog;
+ for (const p of parts) {
+ if (node == null || typeof node !== "object") return undefined;
+ node = node[p];
+ }
+ return typeof node === "string" ? node : undefined;
+}
+
+function interpolate(template, vars) {
+ if (!vars || typeof template !== "string") return template;
+ return template.replace(/\{(\w+)\}/g, (_match, name) => {
+ return vars[name] !== undefined ? String(vars[name]) : _match;
+ });
+}
+
+export function useCronalyticsI18n() {
+ const locale = getLocale();
+ const catalog = CATALOGS[locale] || CATALOGS["en"] || {};
+
+ return function t(key, fallbackOrVars, maybeVars) {
+ let fallback;
+ let vars;
+ if (typeof fallbackOrVars === "string") {
+ fallback = fallbackOrVars;
+ vars = maybeVars;
+ } else {
+ fallback = key;
+ vars = fallbackOrVars;
+ }
+ return interpolate(resolve(key, catalog) ?? fallback, vars);
+ };
+}
diff --git a/dashboard/src/i18n/zh-CN.js b/dashboard/src/i18n/zh-CN.js
new file mode 100644
index 0000000..266b10f
--- /dev/null
+++ b/dashboard/src/i18n/zh-CN.js
@@ -0,0 +1,183 @@
+import { registerCatalog } from "./index.js";
+
+registerCatalog("zh", {
+ // cost
+ cost: {
+ trend_formula: "趋势 % = ((当前成本 − 上期成本) / 上期成本) × 100",
+ what_this_means: "预估成本根据令牌使用量和模型定价计算。实际成本可能因服务商计费粒度而略有差异。",
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "应用自定义天数",
+ go: "确定",
+ label: "天数",
+ },
+ // error
+ error: {
+ message: "出现问题,请刷新页面或联系支持。",
+ title: "Cronalytics 错误",
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "收起横幅",
+ definition_1: "1. Cron 分析与可观测性。",
+ definition_2: "2. Hermes 中智能代理自动化的仪表板。",
+ expand_tooltip: "展开横幅",
+ noun: "(名词)",
+ pronunciation: "/ˈkrɒn.əˌlɪt.ɪks/",
+ tagline: "观察。衡量。优化。",
+ title: "CRONALYTICS",
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "升序",
+ avg_est_cost: "平均预估成本",
+ avg_time: "平均耗时",
+ descending: "降序",
+ est_cost: "预估成本",
+ job: "任务",
+ last: "上次",
+ last_run: "上次运行",
+ mode_agent: "智能体",
+ mode_no_agent: "无智能体",
+ next: "下次",
+ no_jobs_sync: "未捕获到定时任务。点击立即同步从 state.db 回填。",
+ no_jobs_window: "{window} 内无任务。上次同步:{time} UTC",
+ no_schedule: "无计划",
+ nominal_mo: "标称/月",
+ pace: "执行率",
+ runs: "运行",
+ schedule: "计划",
+ see_runs: "查看运行",
+ sort_by: "按 {col} 排序",
+ sorted_by: "按 {col} {dir} 排序",
+ title: "任务明细",
+ trend_mo: "趋势/月",
+ using: "使用",
+ },
+ // job_detail
+ job_detail: {
+ duration: "耗时",
+ error_prefix: "错误:",
+ est_cost: "预估成本",
+ for_full_history: " 查看完整历史。",
+ loading: "加载运行记录...",
+ mode: "模式",
+ mode_agent: "智能体",
+ no_runs: "未找到运行记录。",
+ of: " / ",
+ result: "结果",
+ run: "次运行",
+ runs_plural: "次运行",
+ showing: "显示 ",
+ time: "时间",
+ title_runs: "运行记录",
+ use_cli: "使用 ",
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "最佳执行率",
+ of_total_est_cost: "占预估总成本 %",
+ of_total_runs: "占总运行数 %",
+ of_total_tokens: "占总令牌数 %",
+ title: "排行榜",
+ top_duration: "最长时长",
+ top_est_cost: "最高成本",
+ top_runs: "最多运行",
+ top_tokens: "Token 最多",
+ },
+ // modal
+ modal: {
+ close: "关闭",
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "智能体",
+ all: "全部",
+ label: "模式",
+ no_agent: "无智能体",
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "还有 {n} 个",
+ est_cost: "预估成本",
+ model: "模型",
+ runs: "运行",
+ title: "模型分布",
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "全部",
+ failure: "失败",
+ label: "结果",
+ success: "成功",
+ },
+ // pace
+ pace: {
+ nominal_formula: "标称 = 计划运行次数 × 每次平均成本",
+ pace_formula: "执行率 = 趋势 / 标称",
+ trend_formula: "趋势 = 实际运行次数 × 每次平均成本",
+ what_this_means: "执行率将你的实际支出趋势与你在定时任务定义中设定的预算进行比较。它回答:‘按照这个速度,我是超支还是节约?’",
+ },
+ // runs
+ runs: {
+ trend_formula: "趋势 % = ((当前运行数 − 上期运行数) / 上期运行数) × 100",
+ trend_note: "正值 = 比上期运行更多。负值 = 比上期运行更少。",
+ what_this_means: "所选窗口内记录的定时任务执行总次数。每次运行都会触发你的计划任务——无论成功、失败还是重试。",
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "使用所选窗口折算为 30 天。",
+ breakdown: "明细",
+ color_guide: "颜色说明",
+ green_under_budget: "绿色 (< 1.0×) — 低于预算,支出少于计划。",
+ hide: "隐藏",
+ how_its_calculated: "如何计算",
+ job_details: "任务详情",
+ loading: "加载中…",
+ neutral_budget: "中性 (1.0–2.0×) — 正常范围内,轻微波动。",
+ prior_window_note: "上期对比窗口是将相同时长向后平移所得。",
+ red_over_budget: "红色 (> 2.0×) — 超出预算,支出多于计划。",
+ refresh: "刷新",
+ retry: "重试",
+ show: "显示",
+ showing_window: "显示 ",
+ sync_now: "立即同步",
+ synced_n_runs: "已同步 {n} 次运行",
+ trend_calculation: "趋势计算",
+ what_this_means: "这是什么意思",
+ window_context: "窗口上下文",
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "— 成本(柱状)· ",
+ daily_cost: "每日预估成本",
+ daily_runs: "每日运行",
+ duration_line: "- - 时长",
+ tokens_line: "— Token",
+ },
+ // summary
+ summary: {
+ actual: "实际",
+ all_time: "全部时间",
+ cached: "缓存",
+ cost: "成本",
+ estimated: "预估",
+ in: "输入",
+ job_runs: "任务运行",
+ last_n_days: "最近 {n} 天",
+ no_schedule: "无计划",
+ nominal: "标称",
+ out: "输出",
+ pace: "执行率",
+ period: "周期",
+ tokens: "Token",
+ trend: "趋势",
+ vs_prior: "对比上期",
+ wasted: "浪费",
+ },
+ // tokens
+ tokens: {
+ what_this_means: "令牌是 LLM 使用的计量单位。输入令牌是你的提示词 + 上下文。输出令牌是模型的响应。缓存令牌来自具有相同前缀的重复提示词(更便宜)。",
+ },
+});
\ No newline at end of file
diff --git a/dashboard/src/i18n/zh-TW.js b/dashboard/src/i18n/zh-TW.js
new file mode 100644
index 0000000..2aeb6c4
--- /dev/null
+++ b/dashboard/src/i18n/zh-TW.js
@@ -0,0 +1,183 @@
+import { registerCatalog } from "./index.js";
+
+registerCatalog("zh-TW", {
+ // cost
+ cost: {
+ trend_formula: "趨勢 % = ((目前成本 − 上期成本) / 上期成本) × 100",
+ what_this_means: "預估成本根據令牌使用量和模型定價計算。實際成本可能因服務商計費粒度而略有差異。",
+ },
+ // day_selector
+ day_selector: {
+ apply_custom: "套用自訂天數",
+ go: "確定",
+ label: "天數",
+ },
+ // error
+ error: {
+ message: "發生問題,請重新整理頁面或聯絡支援。",
+ title: "Cronalytics 錯誤",
+ },
+ // hero
+ hero: {
+ collapse_tooltip: "收合橫幅",
+ definition_1: "1. Cron 分析與可觀測性。",
+ definition_2: "2. Hermes 中智能代理自動化的儀表板。",
+ expand_tooltip: "展開橫幅",
+ noun: "(名詞)",
+ pronunciation: "/ˈkrɒn.əˌlɪt.ɪks/",
+ tagline: "觀察。衡量。最佳化。",
+ title: "CRONALYTICS",
+ },
+ // job_breakdown
+ job_breakdown: {
+ ascending: "遞增",
+ avg_est_cost: "平均預估成本",
+ avg_time: "平均耗時",
+ descending: "遞減",
+ est_cost: "預估成本",
+ job: "任務",
+ last: "上次",
+ last_run: "上次執行",
+ mode_agent: "智慧體",
+ mode_no_agent: "無智能代理",
+ next: "下次",
+ no_jobs_sync: "未擷取到定時任務。點擊立即同步從 state.db 回填。",
+ no_jobs_window: "{window} 內無任務。上次同步:{time} UTC",
+ no_schedule: "無排程",
+ nominal_mo: "標稱/月",
+ pace: "執行率",
+ runs: "執行",
+ schedule: "排程",
+ see_runs: "查看執行",
+ sort_by: "按 {col} 排序",
+ sorted_by: "按 {col} {dir} 排序",
+ title: "任務明細",
+ trend_mo: "趨勢/月",
+ using: "使用",
+ },
+ // job_detail
+ job_detail: {
+ duration: "耗時",
+ error_prefix: "錯誤:",
+ est_cost: "預估成本",
+ for_full_history: " 查看完整歷程。",
+ loading: "載入執行記錄...",
+ mode: "模式",
+ mode_agent: "智慧體",
+ no_runs: "未找到執行記錄。",
+ of: " / ",
+ result: "結果",
+ run: "次執行",
+ runs_plural: "次執行",
+ showing: "顯示 ",
+ time: "時間",
+ title_runs: "執行記錄",
+ use_cli: "使用 ",
+ },
+ // leaderboard
+ leaderboard: {
+ most_efficient: "最佳執行率",
+ of_total_est_cost: "佔預估總成本 %",
+ of_total_runs: "佔總執行數 %",
+ of_total_tokens: "佔總令牌數 %",
+ title: "排行榜",
+ top_duration: "最長時長",
+ top_est_cost: "最高成本",
+ top_runs: "最多執行",
+ top_tokens: "Token 最多",
+ },
+ // modal
+ modal: {
+ close: "關閉",
+ },
+ // mode_toggle
+ mode_toggle: {
+ agent: "智能代理",
+ all: "全部",
+ label: "模式",
+ no_agent: "無智能代理",
+ },
+ // model_breakdown
+ model_breakdown: {
+ and_more: "還有 {n} 個",
+ est_cost: "預估成本",
+ model: "模型",
+ runs: "執行",
+ title: "模型分布",
+ },
+ // outcome_toggle
+ outcome_toggle: {
+ all: "全部",
+ failure: "失敗",
+ label: "結果",
+ success: "成功",
+ },
+ // pace
+ pace: {
+ nominal_formula: "標稱 = 計畫執行次數 × 每次平均成本",
+ pace_formula: "執行率 = 趨勢 / 標稱",
+ trend_formula: "趨勢 = 實際執行次數 × 每次平均成本",
+ what_this_means: "執行率將你的實際支出趨勢與你在定時任務定義中設定的預算進行比較。它回答:‘按照這個速度,我是超支還是節約?’",
+ },
+ // runs
+ runs: {
+ trend_formula: "趨勢 % = ((目前執行數 − 上期執行數) / 上期執行數) × 100",
+ trend_note: "正值 = 比上期執行更多。負值 = 比上期執行更少。",
+ what_this_means: "所選視窗內記錄的定時任務執行總次數。每次執行都會觸發你的計畫任務——無論成功、失敗還是重試。",
+ },
+ // shared
+ shared: {
+ all_scaled_30d: "使用所選視窗折算為 30 天。",
+ breakdown: "明細",
+ color_guide: "顏色說明",
+ green_under_budget: "綠色 (< 1.0×) — 低於預算,支出少於計畫。",
+ hide: "隱藏",
+ how_its_calculated: "如何計算",
+ job_details: "任務詳情",
+ loading: "載入中…",
+ neutral_budget: "中性 (1.0–2.0×) — 正常範圍內,輕微波動。",
+ prior_window_note: "上期對比視窗是將相同時長向後平移所得。",
+ red_over_budget: "紅色 (> 2.0×) — 超出預算,支出多於計畫。",
+ refresh: "重新整理",
+ retry: "重試",
+ show: "顯示",
+ showing_window: "顯示 ",
+ sync_now: "立即同步",
+ synced_n_runs: "已同步 {n} 次執行",
+ trend_calculation: "趨勢計算",
+ what_this_means: "這是什麼意思",
+ window_context: "視窗上下文",
+ },
+ // sparkline
+ sparkline: {
+ cost_bar: "— 成本(柱狀)· ",
+ daily_cost: "每日預估成本",
+ daily_runs: "每日執行",
+ duration_line: "- - 時長",
+ tokens_line: "— Token",
+ },
+ // summary
+ summary: {
+ actual: "實際",
+ all_time: "全部時間",
+ cached: "快取",
+ cost: "成本",
+ estimated: "預估",
+ in: "輸入",
+ job_runs: "任務執行",
+ last_n_days: "最近 {n} 天",
+ no_schedule: "無排程",
+ nominal: "標稱",
+ out: "輸出",
+ pace: "執行率",
+ period: "週期",
+ tokens: "Token",
+ trend: "趨勢",
+ vs_prior: "對比上期",
+ wasted: "浪費",
+ },
+ // tokens
+ tokens: {
+ what_this_means: "令牌是 LLM 使用的計量單位。輸入令牌是你的提示詞 + 上下文。輸出令牌是模型的回應。快取令牌來自具有相同前綴的重複提示詞(更便宜)。",
+ },
+});
\ No newline at end of file
diff --git a/dashboard/src/index.js b/dashboard/src/index.js
index 6585af6..cffcba7 100644
--- a/dashboard/src/index.js
+++ b/dashboard/src/index.js
@@ -7,6 +7,13 @@ import { React, PLUGINS } from "./lib/sdk.js";
import { PluginErrorBoundary } from "./components/ErrorBoundary.js";
import { CronalyticsTab } from "./components/CronalyticsTab.js";
+// Register i18n catalogs (en + es) before component renders
+import "./i18n/index.js";
+import "./i18n/en.js";
+import "./i18n/es.js";
+import "./i18n/zh-CN.js";
+import "./i18n/zh-TW.js";
+
PLUGINS.register("cronalytics", function CronalyticsWrapped() {
return React.createElement(
PluginErrorBoundary,
diff --git a/dashboard/src/lib/validate.js b/dashboard/src/lib/validate.js
index 627d47e..7a8704a 100644
--- a/dashboard/src/lib/validate.js
+++ b/dashboard/src/lib/validate.js
@@ -69,7 +69,7 @@ export function validateSync(d) {
export function validateSummary(d) {
assertType("/summary", d, Object);
assertType("/summary", d.total_runs, "number", "total_runs");
- assertType("/summary", d.total_estimated_cost, "number", "total_estimated_cost");
+ assertType("/summary", d.tot_estimated_cost, "number", "tot_estimated_cost");
assertType("/summary", d.total_tokens, "number", "total_tokens");
assertType("/summary", d.success_runs, "number", "success_runs");
assertType("/summary", d.failure_runs, "number", "failure_runs");
@@ -87,7 +87,7 @@ export function validateJobs(d) {
d.jobs.forEach((j, i) => {
assertType("/jobs", j.job_id, "string", `jobs[${i}].job_id`);
assertType("/jobs", j.runs, "number", `jobs[${i}].runs`);
- assertType("/jobs", j.total_cost, "number", `jobs[${i}].total_cost`);
+ assertType("/jobs", j.tot_estimated_cost, "number", `jobs[${i}].tot_estimated_cost`);
assertType("/jobs", j.projections, "object", `jobs[${i}].projections`);
});
}
@@ -101,11 +101,17 @@ export function validateJobRuns(d) {
assertType("/jobs/:id/runs", d.job_id, "string", "job_id");
assertType("/jobs/:id/runs", d.limit, "number", "limit");
assertType("/jobs/:id/runs", d.runs, Array, "runs");
+ if (d.total_runs != null) {
+ assertType("/jobs/:id/runs", d.total_runs, "number", "total_runs");
+ }
+ if (d.more_available != null) {
+ assertType("/jobs/:id/runs", d.more_available, "boolean", "more_available");
+ }
if (IS_DEV && Array.isArray(d.runs)) {
d.runs.forEach((r, i) => {
assertType("/jobs/:id/runs", r.session_id, "string", `runs[${i}].session_id`);
assertType("/jobs/:id/runs", r.run_time, "number", `runs[${i}].run_time`);
- assertType("/jobs/:id/runs", r.estimated_cost_usd, "number", `runs[${i}].estimated_cost_usd`);
+ assertType("/jobs/:id/runs", r.estimated_cost, "number", `runs[${i}].estimated_cost`);
});
}
}
@@ -120,7 +126,7 @@ export function validateModels(d) {
d.models.forEach((m, i) => {
assertType("/models", m.model, "string", `models[${i}].model`);
assertType("/models", m.runs, "number", `models[${i}].runs`);
- assertType("/models", m.total_cost, "number", `models[${i}].total_cost`);
+ assertType("/models", m.tot_estimated_cost, "number", `models[${i}].tot_estimated_cost`);
});
}
}
@@ -134,7 +140,7 @@ export function validateTrends(d) {
if (IS_DEV && Array.isArray(d.trend)) {
d.trend.forEach((t, i) => {
assertType("/trends", t.day, "string", `trend[${i}].day`);
- assertType("/trends", t.cost, "number", `trend[${i}].cost`);
+ assertType("/trends", t.estimated_cost, "number", `trend[${i}].estimated_cost`);
assertType("/trends", t.runs, "number", `trend[${i}].runs`);
});
}
diff --git a/dev/AGENTS.md b/dev/AGENTS.md
deleted file mode 100644
index 5622721..0000000
--- a/dev/AGENTS.md
+++ /dev/null
@@ -1,62 +0,0 @@
-# Project Context: Cron-Insights
-
-This is a custom Hermes Agent plugin/dashboard-plugin.
-
-## Important Notes
-
-- Load skill "project-delivery-lifestyle"
-
-- This project includes frequent Hermes Gateway restarts for testing, please load the skill "checkpoint-resume-pattern"
-
-## ## Background Brief
-
-Review the product brief for context as needed: ./BRIEF.md
-
-## Architecture
-
-Review the architecture and design as needed located here: ./DESIGN.md
-
-## Conventions
-
-## Plan
-
-Reference the build plan ./PLAN.md
-
----
-
-# Cronalytics — Build Session Protocol
-
-## Joint Build Sessions (Live, in-session with Nick)
-
-When building Cronalytics together in real-time:
-
-- **Frontend rendering is Nick's domain.** After I make a change, I commit it, explain what changed, and stop. Nick verifies the UI manually.
-- **Do not automate visual verification** via browser screenshots, DOM clicking, or vision analysis during joint sessions. It's slower, less reliable, and redundant when Nick is present.
-- Exception: backend data flow verification (curl, API responses) is still mine.
-
-This rule applies specifically to Cronalytics during live joint build sessions. Solo overnight or delegated build/test cycles may use full browser automation where appropriate.
-
----
-
-## Release Gate Policy (Effective Immediately)
-
-Cronalytics has reached **mature pre-release stability**. The core feature set is complete, the architecture is proven, and the codebase is now in a pre-launch testing and hardening phase.
-
-### Branch Discipline
-
-- **All changes must land on a feature branch first.** Never commit directly to `master`.
-- Branch naming: `feat/` or `fix/` or `ui/`.
-- Each branch must be tied to a specific issue, assessment item, or launch-plan task.
-
-### Certification Requirement
-
-Before any branch is merged into `master`, it must be **certified by both Phosphor and Nick**:
-
-1. **Phosphor certifies technical correctness:** tests pass, lint is green, no regressions in existing functionality, assessment recommendations are addressed or explicitly deferred.
-2. **Nick certifies product readiness:** UI/UX is verified manually across devices, copy and behavior match intent, and the change does not destabilize the pre-release build.
-
-Neither party can override the other. If Phosphor rejects a branch, it does not merge. If Nick rejects a branch, it does not merge.
-
-### Rationale
-
-We are past the experimentation phase. The `master` branch must remain a known-good, launch-candidate state at all times. Disposable experimentation belongs in branches. Merge is a deliberate, dual-signature act.
diff --git a/dev/BRIEF.md b/dev/BRIEF.md
index 74dce10..5ff9ef2 100644
--- a/dev/BRIEF.md
+++ b/dev/BRIEF.md
@@ -40,15 +40,15 @@ A concise positioning line for the product is: **Turn hidden automation into vis
Potential names for the initiative or feature include:
-- **Cron Cost Analytics**.hermes-agent.nousresearch+1
+- ~~**Cron Cost Analytics**~~
-- **Cron Spend Visibility**.hermes-agent.nousresearch+1
+- ~~**Cron Spend Visibility**~~
-- **Background Automation Cost Tracking**.hermes-agent.nousresearch+1
+- ~~**Background Automation Cost Tracking**~~
-- **Unattended AI Cost Insights**.hermes-agent.nousresearch+1
+- ~~**Unattended AI Cost Insights**~~
-- **Cron-Insights**<---- *chosen (current) product name*
+- **Cronalytics** — the dashboard for agentic automations in Hermes
## Short Executive Pitch
diff --git a/dev/DESIGN.md b/dev/DESIGN.md
index 966e48a..1dcd8f3 100644
--- a/dev/DESIGN.md
+++ b/dev/DESIGN.md
@@ -22,7 +22,7 @@ The result: automation is easy to start, but ongoing cron cost compounds quietly
## 2. Solution
-Cronalytics is a **dashboard plugin** (plus a standalone CLI) that attributes session-level usage and estimated cost to cron-originated runs. It lives inside `hermes dashboard` as a standalone tab at `/cronalytics`.
+Cronalytics is a **dashboard plugin** with an optional terminal CLI that attributes session-level usage and estimated cost to cron-originated runs. It lives inside `hermes dashboard` as a dedicated tab at `/cronalytics`.
> **Terminology (as of Hermes 2026-05):**
> - **Hermes Agent plugin** — Has a `plugin.yaml`, registers hooks (e.g. `on_session_end`), runs inside the gateway process. Cronalytics is this.
@@ -124,6 +124,8 @@ We deliberately chose **non-blocking** ingestion: the hook writes the `session_i
~/.hermes/plugins/cronalytics/facts.db
```
+**Concurrency:** The database explicitly enables **WAL (Write-Ahead Logging)** mode via `PRAGMA journal_mode=WAL;`. This ensures the background ingester thread can write new runs while the Dashboard API performs aggregation queries simultaneously without "Database is locked" errors.
+
Why separate?
- `state.db` is operational. It may be purged, migrated, or schema-migrated by Hermes core.
- Fact DB rows are **INSERT-only**. No updates, no deletes. If upstream data changes, the snapshot remains.
@@ -160,17 +162,19 @@ CREATE TABLE cron_runs (
### 4.4 Reconciliation Scanner
-The scanner exists because hooks can crash, the plugin can be disabled, or the gateway can restart. It is **not** the primary data path — hooks capture ~99% of events in real time — but it is the safety net.
+The scanner exists because hooks can crash, the plugin can be disabled, or the gateway can restart. It is **not** the primary data path — hooks capture ~99% of events in real time — but it is the safety net. It specifically targets both `state.db` (for agent sessions) and `~/.hermes/cron/output/` (for script-only `no_agent` jobs).
**Algorithm:**
-1. Read watermark JSON (`last_ended_at`).
+1. Read watermark JSON (`last_ended_at` for agent jobs; `last_modified` for script offsets).
2. Query `state.db` for `source='cron'` rows with `ended_at > watermark`.
-3. Batch-insert new rows into fact DB.
-4. Write new watermark = `max(ended_at)`.
+3. Scan filesystem for script output artifacts newer than watermark.
+4. Batch-insert new rows into fact DB.
+5. Write new watermark.
**Trigger sources:**
- Bootstrap thread on every plugin load (catches gaps from downtime).
- Manual `POST /api/plugins/cronalytics/sync` ("Sync Now" button).
+- Background worker fallback if `on_session_end` fails to resolve a session.
### 4.5 Standalone `/cronalytics` Tab
@@ -180,19 +184,19 @@ The original design specified three slots (`cron:top`, `cron:bottom`) injected i
2. Vertical slice delivery: a full page is faster to build and test than coordinating multiple slot injections.
3. Navigation clarity: users expect "Cronalytics" as a distinct view, not a patch on top of the scheduler CRUD.
-The manifest no longer claims any sidebar slots. The `/cronalytics` tab is the sole UI surface.
+The `/cronalytics` tab is the primary UI surface.
### 4.6 Fixed-Window Projection Math
Early versions used the *data span* (actual days between first and last run) as the denominator for trend calculations. This broke an algebraic invariant: the sum of per-job trends did not equal the aggregate trend, because each job had a different data span.
-**Decision:** Use the user's selected filter window (7D, 30D, 90D, All) as the fixed denominator for all trend math.
+**Decision:** Use the user's selected filter window (7D, 30D, 90D) as the fixed denominator for all trend math. Type `0` for all time.
| Metric | Formula |
|--------|---------|
-| Daily cost | `total_cost / days_filter` *(or all-time span if days=0)* |
+| Daily cost | `tot_estimated_cost / days_filter` *(or all-time span if days=0)* |
| Trend 30d | `daily_cost × 30` |
-| Nominal 30d | `avg_cost × scheduled_runs_30d` *(from croniter)* |
+| Nominal 30d | `avg_estimated_cost × scheduled_runs_30d` *(from croniter)* |
| Pace | `trend_30d / nominal_30d` |
| Drift | `observed_runs / scheduled_runs_in_window` *(API only; not surfaced in UI yet)* |
@@ -319,7 +323,7 @@ run_job() ──▶ run_conversation() ──▶ on_session_end(platform="cron")
- Surface aggregates (total cost, runs, tokens, pace) in a dashboard tab.
- Project future spend based on schedule (nominal) and current pace (trend).
- Distinguish agent vs script-only jobs.
-- Provide a standalone CLI for terminal-based inspection.
+- Provide a terminal CLI for terminal-based inspection.
### What Cronalytics Does NOT Do
- **Create or edit jobs** — use the built-in `/cron` page.
@@ -333,34 +337,96 @@ run_job() ──▶ run_conversation() ──▶ on_session_end(platform="cron")
## 7. i18n / Localization
-**Status:** English-only. Hermes core has an i18n system (`useI18n`, 16 locales: `en`, `zh`, `zh-hant`, `ja`, `de`, `es`, `fr`, `tr`, `uk`, `af`, `ko`, `it`, `ga`, `pt`, `ru`, `hu`), but **zero existing plugins integrate with it** — including Kanban and hermes-achievements, which are core-bundled.
+**Status:** Production-ready Multi-locale support (`en`, `es`, `zh-CN`, `zh-TW`).
+
+**Architecture:**
+Cronalytics implements a **self-hosted i18n layer** that bridges with the Hermes Core `locale` state. While other bundled plugins (Kanban, Achievements) consume translations directly via `SDK.useI18n()` and rely on Hermes' built-in catalogs, Cronalytics maintains its own catalog registry (`registerCatalog`) to support languages and technical terminology beyond what Hermes core provides.
+
+**Why self-hosted:**
+1. **Independent locale control:** Hermes core supports 16 locales, but Cronalytics may need languages or regional variants (e.g., `zh-TW`) that are not in the core bundle.
+2. **Product Glossary enforcement:** Technical terms like "Pace" require precise, domain-specific translation that generic Hermes catalogs cannot guarantee.
+3. **The "2/4 Consensus" Protocol:** Our multi-model validation pipeline requires owning the entire translation catalog to enforce statistical agreement and outlier rejection.
+
+**How other plugins do it:**
+Kanban and Achievements call `const { t } = SDK.useI18n()` directly. Hermes exposes a namespaced translation object (e.g., `t.kanban.title`). This is simpler but limits plugins to whatever languages Hermes ships.
+
+**Developer Requirement:**
+Zero hardcoded strings in JSX. Every label must use the `useCronalyticsI18n()` hook with a technical key + English fallback. See `docs/I18N_PROTOCOL.md` and `AGENTS.md`.
+
+---
+## 8. Terminal CLI
+
+Cronalytics ships a terminal data tool (`cronalytics/cli.py`) that queries `facts.db` directly and renders monospace-aligned ASCII tables or `--json` envelopes. It is designed for **scripts, agents, and programmatic consumption** — not human visual exploration.
+
+### Design Philosophy: Dashboard for People, CLI for Agents
+
+The CLI is a **dumb data pipe**. It aggregates, formats, and emits. It never interprets.
+
+| Layer | Role | Consumer |
+|-------|------|----------|
+| **CLI** | Data pipe | Scripts, agents, `jq`, Python |
+| **Skill** | Interpretation framework | Hermes agent (fuzzy reasoning) |
+| **Agent** | Force multiplier | Human operator |
+| **Human** | Final authority | Decision maker |
+
+### Architecture
+
+```
+User / Agent
+ │
+ ▼
++------------+ +------------+ +------------+
+│ Skill │ → │ CLI │ → │ facts.db │
+│ (heuristics,│ │ (queries, │ │ (append- │
+│ guardrails,│ │ renders) │ │ only) │
+│ confidence)│ +------------+ +------------+
++------------+ ↑
+ │
+ state.db
+ (cron sessions)
+```
+
+### CLI Design Decisions
+
+1. **Single file** (`cli.py`, ~1000 lines) — self-contained, no external deps beyond Python stdlib + croniter. Works from the plugin directory directly.
+2. **Shell entry point** — `cronalytics` (via `pip install -e`) or `alias cronalytics='python -m cronalytics.cli'` for the module path.
+3. **Every data command except `all` supports `--json`** — structured envelopes with `period`, `start_date`, `end_date`, `outcome`, `mode`, and `data`. Pipe-friendly.
+4. **Job name resolution** — reads `~/.hermes/cron/jobs.json` to map `job_id` → human-readable name, applies truncation + `[N]` badge.
+5. **Projection computation** — calls `schedule.get_job_projections()` per-job to compute `pace`, `drift_ratio`, `scheduled_runs_*`, etc. JSON path mirrors rendered path exactly.
+6. **Leader Board** — `summary` command selects top job per category (runs, cost, tokens, pace) and computes `% of total` share, matching the dashboard's spotlight cards.
+7. **ASCII art banners** — Unicode box-drawing with emoji-aware width calculation (`_visual_len()`). Consistent with `hermes insights` visual style.
+
+### Filter Grammar
-**Why:** Hermes plugins are loaded as `