Skip to content

feat(dashboard): shared table framework with filtering, pagination, selection, and bulk actions#415

Merged
njbrake merged 21 commits into
mainfrom
feat/shared-table-framework
Jul 24, 2026
Merged

feat(dashboard): shared table framework with filtering, pagination, selection, and bulk actions#415
njbrake merged 21 commits into
mainfrom
feat/shared-table-framework

Conversation

@njbrake

@njbrake njbrake commented Jul 24, 2026

Copy link
Copy Markdown
Member

Description

Builds one shared table framework for the dashboard and adopts it across every table page, so filtering, pagination, and row selection work the same everywhere, plus two backend endpoints for operator cleanup of imported usage. Implements #409.

Highlights:

  • Shared framework on HeroUI v3's react-aria Table: DataTable (selection, sort, keyboard grid nav, column resize), TablePagination (rows-per-page plus first / prev / type-a-page / next / last), useTableSelection + BulkActionBar (with a "select all N matching this filter" affordance), ConfirmDialog, SetPriceDialog, and URL-synced filter/pagination helpers.
  • All nine table pages migrated (Activity, Usage, Models, API keys, Users, Budgets, Aliases, Providers, Overview), each opting into selection and bulk actions per the issue's matrix (bulk delete / set-price on Activity and Usage, bulk delete on Aliases and Budgets, delete plus assign-budget on Users, disable / budget-exempt / delete on API keys, bulk set-pricing on Models). The hand-rolled components/Table.tsx and its custom column-resize code are deleted, which closes Dashboard: shrinking a resized table column leaves a white gap inside the card #403 (the resize / white-gap bug lived in that code; HeroUI owns resizing now).
  • Backend (master-key, standalone):
    • DELETE /v1/usage: delete imported usage rows by an explicit ids list or by filter.
    • POST /v1/usage/set-price: recompute cost / billing meters / pricing breakdown from manual per-1M input / output / cache-read / cache-write rates against each row's own token counts (reuses services/metered_pricing.calculate_metered_cost).
    • Both target rows two ways (ids or by_filter) and are scoped to imported rows only (counts_toward_budget = false), so enforced gateway rows and the spend ledger (users.spend) are never touched.
    • Adds priced and counts_toward_budget read filters on the usage list / count / summary so the toolbar can filter unpriced rows and back the "select all N matching" count.
  • Also included, separate from the framework, at the maintainer's request: model discovery is now scoped to configured providers only. Previously a provider callable by a credential env var alone (for example OPENAI_API_KEY present in the environment) populated the Models page even with no providers configured; now an empty gateway lists no discovered models until a provider is configured in config.yml or via the Providers page.

Design / QA notes

The pages were dogfooded in a real browser with seeded data (Playwright), which drove a couple of header-styling iterations. The final approach makes .otari-table the single card owner rather than wrapping HeroUI's Table.Root (which is itself a card, with its own surface, 32px radius, and inner padding) in a second card, so the brand-tint header sits flush inside the card corner on both sides.

Testing

make lint and make typecheck are clean. The full unit suite (942) and the web suite (243) pass, along with every integration test covering the changed areas (159: usage admin, usage list/count/summary, external usage events, model discovery, aliases, hybrid surface, config env loading). The entire integration suite was not run end to end in one local process because the sandbox Postgres was OOM-killed under full-suite load; CI runs the complete suite.

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

Fixes #409
Closes #403

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test; see the Testing note above for the one caveat on the full integration run).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used: Claude Code (Opus 4.8)

Any additional AI details you'd like to share:

The code was generated by Claude Code via back-and-forth with @njbrake. The scope, product decisions (the bulk-action matrix, the discovery-scope choice), and the visual QA feedback that shaped the design are his; the implementation and prose are the AI's.

  • I am an AI Agent filling out this form (check box if true)

Summary

  • Introduced a shared, accessible dashboard table system with consistent filtering, URL-synced state, pagination, sorting, selection, resizing, keyboard navigation, and bulk actions.
  • Migrated all dashboard table pages to the shared components and removed the legacy table and resize implementation, eliminating the white-gap issue.
  • Added bulk delete and manual pricing administration for imported usage records, with gateway and spend-ledger protections.
  • Added usage pricing, budget-scope, and source filters across list, count, summary, export, and dashboard views.
  • Restricted model discovery to configured providers.
  • Added reusable dialogs, selection utilities, pagination controls, custom date ranges, and comprehensive frontend/backend tests.

njbrake and others added 16 commits July 24, 2026 00:48
Adds two master-key, standalone endpoints the dashboard's usage tables need
for operator cleanup that today requires direct DB access:

- DELETE /v1/usage: remove imported usage rows by an explicit id list or by
  filter (source/model/user_id/status/date range/priced).
- POST /v1/usage/set-price: recompute cost/billing_meters/pricing_breakdown
  from manual per-1M input/output/cache-read/cache-write rates against each
  row's own token counts, reusing calculate_metered_cost.

Both target rows two ways: an explicit `ids` list (the current UI selection)
or `by_filter: true` plus filter fields (everything matching). by_filter is
required for the filter path so an empty body can never match every row.

Safety invariant: every query is scoped to counts_toward_budget = false, so
only imported rows are ever touched. Enforced gateway rows and the spend
ledger (users.spend) are never affected, matching the ingest boundary.

Also extends the /v1/usage list, count, and summary reads with `priced` and
`counts_toward_budget` filters so the dashboard can filter unpriced rows and
back the "select all N matching this filter" affordance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the reusable building blocks for consistent filtering, pagination, and
row selection across the dashboard's tables, built on HeroUI v3's compound
Table (a thin wrapper over react-aria-components) so selection, sort, keyboard
grid navigation, and column resize come from the library instead of bespoke
code:

- DataTable: declarative columns + rows with optional multi-select (react-aria
  selection slot), sort, disabled rows (selection-only), loading/empty states,
  and opt-in column resize.
- TablePagination: rows-per-page plus first / prev / type-a-page / next / last
  with a truthful "range of total" summary that stays honest when the total is
  unknown.
- useTableSelection + resolveSelectedIds: page selection plus an "all matching
  this filter" intent so a bulk op can be set-scoped instead of id-scoped.
- BulkActionBar: the contextual selection bar with the "select all N matching"
  affordance.
- urlState (useUrlParam / useUrlNumberParam): keep filter/pagination state in
  the URL so a view is shareable and back-button friendly.

Declares react-aria-components as a direct dependency (already present
transitively via HeroUI) since the framework now builds on it directly. Each
primitive ships with Vitest coverage; no page behavior changes yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dashboard Activity log filters by API key, so the delete / set-price
"select all matching this filter" path must honor api_key_id too; otherwise a
set-scoped bulk op would silently ignore that filter. Adds api_key_id to the
usage mutation selection and regenerates the OpenAPI spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuilds the Activity page on the shared DataTable / TablePagination /
BulkActionBar primitives, wiring the new usage mutations end to end:

- Row selection with a contextual bulk-action bar. Only imported rows are
  selectable (enforced gateway rows are disabled), so Delete and Set price can
  never touch budget-affecting rows. A "select all N matching this filter"
  affordance drives set-scoped bulk ops.
- Delete (confirm dialog) and Set price (manual per-1M rate dialog) call the new
  DELETE /v1/usage and POST /v1/usage/set-price endpoints.
- Fast pagination: rows-per-page plus first / prev / type-a-page / next / last.
- All filter + pagination state is URL-synced (shareable, back-button friendly),
  adds a "Priced?" filter and a custom from/to range, and still honors the
  start_date/end_date drill-down from the Usage page.
- The request detail moves from an in-table expand row to a panel below the
  table (react-aria's table collection has no place for an arbitrary sub-row).

Adds useDeleteUsage / useSetUsagePrice hooks (invalidating every usage view) and
the priced / counts_toward_budget filter params, plus the ConfirmDialog and
SetPriceDialog components. Rebuilds the committed dashboard bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HeroUI's Table paints its header row and columns with --surface-secondary, a
gray that reads wrong over the white card. Override the header with the brand
tint and transparent, ink-colored columns so the table chrome matches the rest
of the dashboard, as the previous hand-rolled table did.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the Aliases table to the shared DataTable and adds row selection with
a bulk delete (confirm dialog). Only stored aliases are selectable; config.yml
aliases stay read-only. Per-row Edit/Delete are unchanged. There is no bulk
alias endpoint, so bulk delete removes the selected aliases sequentially.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the Budgets table to the shared DataTable with row selection and a
bulk delete (confirm dialog). The per-row reset-history moves from an in-table
sub-row to a panel below the table (react-aria's table collection has no place
for an arbitrary sub-row), and Edit is now an explicit row action rather than a
whole-row click so it never collides with the selection checkboxes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the Users table to the shared DataTable with row selection and bulk
actions: bulk delete and bulk "assign budget" (a budget picker dialog applied
to the selection). Block/Unblock, Edit, and per-row Delete remain; Edit is an
explicit row action rather than a whole-row click so it never collides with the
selection checkboxes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the API keys table to the shared DataTable with row selection and bulk
actions: bulk Disable (revoke), bulk Budget-exempt, and bulk Delete. Bulk delete
targets only already-disabled keys, mirroring the per-row rule that a live key
must be disabled before it can be permanently deleted. Per-row Disable/Enable,
Edit, Regenerate, and Delete remain; Edit is an explicit row action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swaps the Providers table onto the shared DataTable. No selection or bulk
actions (the matrix keeps Providers row-scoped): per-row Test / Edit / Delete
and the health pill are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty list

Swaps the Overview "recent activity" preview onto the shared DataTable. It stays
read-only (no selection); the "View all" link still opens the full Activity log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates the Models catalog onto the shared framework:

- The table is now DataTable with react-aria sort (the Model and price columns
  stay sortable, persisted to localStorage) and row selection.
- Pagination moves to the shared TablePagination (first / prev / type-a-page /
  next / last), replacing the bespoke pager.
- Row selection drives a bulk "Set pricing" action (a rates dialog applied to
  the selection, or to all filtered models via "select all matching").
- The inline price-cell editor moves from an in-table sub-row to a panel below
  the table; the model detail side-panel is unchanged.

Parameterizes SetPriceDialog's title/description so it reads correctly for both
usage repricing and model pricing. Removes the now-dead bespoke Pagination and
SortableTh helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…table

Migrates the Usage analytics page and retires the old table:

- The spend-by-model / spend-by-user breakdowns move onto the shared DataTable
  (row-click still drills into the Activity log; the fold and deleted-user rows
  stay non-drillable).
- Adds an "Individual requests" section under the breakdowns: the raw usage rows
  for the current filter window, paginated, with row selection and the
  imported-row bulk actions (Delete, Set price), matching the Activity log. Only
  imported rows are selectable, so bulk ops never touch enforced rows.
- Deletes components/Table.tsx and its test: every page now uses DataTable, so
  the hand-rolled table and its custom column-resize code are gone. Closes #403
  (the resize/white-gap bug lived in that code; HeroUI's Table owns resizing now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Model discovery previously included any any-llm provider made callable by a
credential env var alone (e.g. OPENAI_API_KEY in the environment), so an empty
gateway with no configured providers still listed models on the Models page.

Scope discovery to the configured provider instances (config.providers, which
is the providers: block plus anything added via the Providers page). A provider
now only sources models once an operator has configured it, so an empty gateway
lists nothing even when a provider's credential env var happens to be present.

Removes the env-var auto-detection (_env_provider_instances) and its tests, and
flips the integration coverage to assert an env-only provider is not listed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HeroUI's Table decorates its header with vertical column separators, heavy
pill corner-radii on the first/last columns, and a gray surface, which read as
an ugly floating band over the dashboard's white cards. Neutralize those in
globals.css (scoped to `.otari-table`, placed after the HeroUI import so the
rules win the cascade without `!important`): the header is now a flat brand-tint
strip with a single hairline bottom border and no column dividers, and
`overflow-hidden` on the table root clips it flush to the card's rounded corner.
Also center the empty / loading state instead of cramping it at the left edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e corner

The header corners still did not line up with the card because HeroUI's
Table.Root is already a card: its `primary` variant gives the root a gray
surface, a 32px border-radius, and 4px inner padding. Wrapping it in a second
card (our border + rounded-xl) left two mismatched radii and a 4px inset gap, so
the brand-tint header floated inside the corner instead of meeting it.

Make `.otari-table` the single card: neutralize the variant's surface, radius,
and padding, and own the container styling (surface, 1px border, one 12px
radius, overflow-hidden). The header fills to the edges and clips to that one
radius, so header and card share the exact same corner on both sides. The
first/last-column radius selectors are replicated with the `.otari-table` prefix
so they outrank HeroUI's more specific corner rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

This PR adds imported-usage filtering, deletion, and manual repricing APIs; narrows model discovery to configured providers; and introduces shared dashboard tables, pagination, URL filters, selection, dialogs, and bulk actions across multiple pages.

Usage administration

Layer / File(s) Summary
Usage filters and imported-row mutations
src/gateway/api/routes/usage.py, src/gateway/services/usage_admin_service.py, tests/integration/test_usage_admin.py, docs/public/otari.postman_collection.json
Usage endpoints support priced and counts_toward_budget; master-key routes delete and reprice imported rows, with validation and integration coverage.
Usage API client support
web/src/api/types.ts, web/src/api/hooks.ts
Usage filter types, mutation payloads/results, query serialization, and delete/set-price hooks are added.
Activity and usage workflows
web/src/pages/ActivityPage.tsx, web/src/pages/UsagePage.tsx, related tests and bundled assets
URL-backed filters, custom date windows, pagination, imported-row selection, all-matching operations, confirmations, and manual pricing dialogs are added.

Configured-provider discovery

Layer / File(s) Summary
Configured-provider discovery scope
src/gateway/services/model_discovery_service.py, tests/integration/test_model_discovery.py, tests/unit/test_gateway_model_discovery.py
Model discovery and operator listings now use configured provider instances only; environment-only provider tests are updated accordingly.

Shared dashboard framework

Layer / File(s) Summary
Shared table and interaction primitives
web/src/components/*, web/src/lib/*, web/package.json, component and utility tests
React Aria table, selection, pagination, URL-state, confirmation, and manual-pricing primitives are introduced with tests.
Management page adoption
web/src/pages/{Aliases,Budgets,Keys,Models,Overview,Providers,Users}Page.tsx, related tests
Management pages migrate to DataTable and add page-specific selection, sorting, pagination, and bulk actions.
Dashboard build and styling
src/gateway/static/dashboard/assets/*, src/gateway/static/dashboard/index.html, src/gateway/static/dashboard/assets/*, src/gateway/static/dashboard/index.html, src/gateway/static/dashboard/assets/index-pOfhHB4P.css, src/gateway/static/dashboard/assets/*
Compiled dashboard modules, dependency hashes, generated CSS, preload mappings, and HTML references are refreshed.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • mozilla-ai/otari#334 — Shares dashboard filter-control wiring in Activity and Models pages.
  • mozilla-ai/otari#356 — Overlaps with cached-token pricing fields used by manual repricing.
  • mozilla-ai/otari#392 — Introduced usage-row source and budget-participation semantics used by these operations.

Suggested labels: missing-template

Suggested reviewers: khaledosman

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title is relevant, but it uses feat(dashboard): instead of the required feat: prefix and exceeds the ~70 character target. Use feat: and keep it under ~70 chars, e.g. feat: add shared dashboard table framework.
Out of Scope Changes check ⚠️ Warning Model-discovery scope changes and related test deletions are unrelated to [#403, #409] and extend beyond the requested dashboard table/usage work. Move the model-discovery changes to a separate PR or add a linked issue that explicitly covers them.
Docstring Coverage ⚠️ Warning Docstring coverage is 3.93% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description check ✅ Passed The description matches the required template and includes the summary, PR type, issues, checklist, and AI-usage sections.
Linked Issues check ✅ Passed The PR covers [#403, #409] with shared table primitives, page migrations, usage bulk endpoints, and removal of the custom resize code.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shared-table-framework
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/shared-table-framework

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@njbrake
njbrake marked this pull request as ready for review July 24, 2026 10:49
@coderabbitai
coderabbitai Bot requested a review from khaledosman July 24, 2026 10:51
@khaledosman
khaledosman requested a review from Copilot July 24, 2026 11:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
src/gateway/services/usage_admin_service.py (1)

198-219: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Partial commits on a mid-run failure, plus an unbounded id load.

Two related things in the repricing loop, neither catastrophic but worth a look:

  1. Commit inside the loop (Line 218-219): if a later chunk raises SQLAlchemyError, the rollback() only unwinds the current chunk while earlier chunks stay committed, so a failed reprice can leave the selection half-updated. The saving grace is idempotency — re-running reports the already-updated rows as unchanged — but it does mean the returned result counts are never produced on the failing run. Worth a docstring note that partial application is possible, or accumulating and committing once if the worst-case set is bounded.

  2. Unbounded id materialization (Line 198): for the by_filter path this pulls every matching imported id into memory before chunking. On a large imported table that list can grow without bound.

♻️ One option: bound the operation up front
    ids = list((await db.execute(
        select(UsageLog.id).where(*conditions).order_by(UsageLog.id)
    )).scalars().all())
    # e.g. reject or cap when len(ids) exceeds a configured ceiling to keep the
    # transaction and memory footprint predictable.

As per coding guidelines: "Group related writes in one transaction and commit once rather than committing inside loops" and "Avoid unbounded in-memory accumulation ... never select an unbounded growing table such as UsageLog".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/usage_admin_service.py` around lines 198 - 219, Update
the repricing flow around _REPRICE_CHUNK to avoid materializing all matching
UsageLog IDs at once, using bounded pagination or an enforced configured limit.
Group all chunk updates into one transaction and commit only after every chunk
succeeds, rolling back on failure so partial results are not committed. Preserve
the existing UsageSetPriceResult counters and pricing calculations.

Source: Coding guidelines

src/gateway/api/routes/usage.py (1)

174-179: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add an Alembic migration for the new UsageLog query indexes.

counts_toward_budget now constrains the imported-usage selection path and cost filtering is added to the endpoint, but UsageLog only has FK/timestamp/status/source indexes and no migration for a counts_toward_budget or covering composite index. Add the index in Alembic to keep these list/count/delete/repricing paths from falling back to a full table scan as the table grows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/usage.py` around lines 174 - 179, Add an Alembic
migration for the UsageLog query paths, creating indexes that cover
counts_toward_budget filtering and the combined imported-usage/cost selection
predicates used by the endpoint. Follow the project’s existing migration
conventions, include the indexes in upgrade and remove them in downgrade, and
reference the UsageLog columns consistently with the model.

Source: Coding guidelines

web/src/components/ConfirmDialog.tsx (1)

21-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add colocated dialog tests.

Add ConfirmDialog.test.tsx covering controlled open/close behavior, cancel, confirm callback, and mutation-error rendering. This is a destructive-action component, so a little test coverage is cheap insurance.

As per coding guidelines, “Add colocated Vitest tests for changed behavior” and “render real providers.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/ConfirmDialog.tsx` around lines 21 - 59, Add a colocated
Vitest suite for the ConfirmDialog component covering controlled open/close
state, cancel invoking onOpenChange(false), confirm invoking onConfirm, and
rendering mutation errors through ErrorBanner. Render the component with the
real providers required by the dialog and use the existing project testing
conventions.

Source: Coding guidelines

web/src/components/DataTable.test.tsx (1)

63-64: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert accessible table state instead of harness test IDs.

These assertions validate internal state through data-testid. Assert the selected checkbox and sortable column-header state through getByRole instead, matching how users and assistive technology observe the table.

As per coding guidelines, “query the UI as a user would with getByRole, getByLabelText, or getByText instead of getByTestId.”

Also applies to: 108-109

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/DataTable.test.tsx` around lines 63 - 64, Replace the
data-testid-based assertions in the DataTable tests, including the cases around
the selected count and lines 108-109, with role-based queries that verify the
selected checkbox and sortable column-header state as users and assistive
technologies observe them. Use getByRole with appropriate accessible names and
state options, and remove reliance on the count test ID.

Source: Coding guidelines

src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js (1)

1-2: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Sequential, uncapped bulk-mutation loops across five pages. Each page's bulk action (delete, disable, budget-assign, or price) issues one mutateAsync call per selected row inside a plain sequential loop, with no cap on how many rows can be targeted at once — most risky on ModelsPage, where "select all matching" can target every model matching the current filter.

  • src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js#L1-L2: cap or batch the mt pricing loop over ht (especially the allMatching branch, which can include the entire filtered set), or add a size confirmation before submitting very large selections.
  • src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js#L1-L2: consider a similar cap/batch for the w bulk-delete loop over v.
  • src/gateway/static/dashboard/assets/BudgetsPage-ewpsdquY.js#L1-L2: consider a similar cap/batch for the Z bulk-delete loop over S.
  • src/gateway/static/dashboard/assets/KeysPage-B1eIYMxz.js#L1-L5: consider a similar cap/batch for the shared R bulk-action helper used by disable/exempt/delete.
  • src/gateway/static/dashboard/assets/UsersPage-D6fvttL7.js#L1-L2: consider a similar cap/batch for the shared L bulk-action helper used by delete/budget-assign.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js` around lines 1 -
2, Cap or batch bulk mutation requests to prevent uncapped sequential
operations: update mt in
src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js:1-2, w in
src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js:1-2, Z in
src/gateway/static/dashboard/assets/BudgetsPage-ewpsdquY.js:1-2, shared R in
src/gateway/static/dashboard/assets/KeysPage-B1eIYMxz.js:1-5, and shared L in
src/gateway/static/dashboard/assets/UsersPage-D6fvttL7.js:1-2. Ensure select-all
and large selections are bounded through batching, a hard cap, or an explicit
size confirmation before submission.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/src/components/SetPriceDialog.tsx`:
- Around line 74-77: Reset the local form state when each dialog opens: in
web/src/components/SetPriceDialog.tsx at lines 74-77, clear input, output,
cacheRead, and cacheWrite when isOpen becomes true, allowing error state to
reset as well; in web/src/pages/UsersPage.tsx at line 295, reset budgetId to an
empty string when AssignBudgetDialog opens.

---

Nitpick comments:
In `@src/gateway/api/routes/usage.py`:
- Around line 174-179: Add an Alembic migration for the UsageLog query paths,
creating indexes that cover counts_toward_budget filtering and the combined
imported-usage/cost selection predicates used by the endpoint. Follow the
project’s existing migration conventions, include the indexes in upgrade and
remove them in downgrade, and reference the UsageLog columns consistently with
the model.

In `@src/gateway/services/usage_admin_service.py`:
- Around line 198-219: Update the repricing flow around _REPRICE_CHUNK to avoid
materializing all matching UsageLog IDs at once, using bounded pagination or an
enforced configured limit. Group all chunk updates into one transaction and
commit only after every chunk succeeds, rolling back on failure so partial
results are not committed. Preserve the existing UsageSetPriceResult counters
and pricing calculations.

In `@src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js`:
- Around line 1-2: Cap or batch bulk mutation requests to prevent uncapped
sequential operations: update mt in
src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js:1-2, w in
src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js:1-2, Z in
src/gateway/static/dashboard/assets/BudgetsPage-ewpsdquY.js:1-2, shared R in
src/gateway/static/dashboard/assets/KeysPage-B1eIYMxz.js:1-5, and shared L in
src/gateway/static/dashboard/assets/UsersPage-D6fvttL7.js:1-2. Ensure select-all
and large selections are bounded through batching, a hard cap, or an explicit
size confirmation before submission.

In `@web/src/components/ConfirmDialog.tsx`:
- Around line 21-59: Add a colocated Vitest suite for the ConfirmDialog
component covering controlled open/close state, cancel invoking
onOpenChange(false), confirm invoking onConfirm, and rendering mutation errors
through ErrorBanner. Render the component with the real providers required by
the dialog and use the existing project testing conventions.

In `@web/src/components/DataTable.test.tsx`:
- Around line 63-64: Replace the data-testid-based assertions in the DataTable
tests, including the cases around the selected count and lines 108-109, with
role-based queries that verify the selected checkbox and sortable column-header
state as users and assistive technologies observe them. Use getByRole with
appropriate accessible names and state options, and remove reliance on the count
test ID.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9112f95f-f4b5-44c9-b2ad-b248c88695d5

📥 Commits

Reviewing files that changed from the base of the PR and between 6a8a165 and 9961296.

⛔ Files ignored due to path filters (3)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
  • node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json is excluded by !**/node_modules/**
  • web/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (75)
  • src/gateway/api/routes/usage.py
  • src/gateway/services/model_discovery_service.py
  • src/gateway/services/usage_admin_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-BbcN3Ifw.js
  • src/gateway/static/dashboard/assets/ActivityPage-DLDDQ1lo.js
  • src/gateway/static/dashboard/assets/AliasesPage-BSEuvruJ.js
  • src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js
  • src/gateway/static/dashboard/assets/BudgetsPage-CofqydTM.js
  • src/gateway/static/dashboard/assets/BudgetsPage-ewpsdquY.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-DFHlLPE5.js
  • src/gateway/static/dashboard/assets/DataTable-mgItq60H.js
  • src/gateway/static/dashboard/assets/Field-DEJ6Wjg9.js
  • src/gateway/static/dashboard/assets/KeysPage-B1eIYMxz.js
  • src/gateway/static/dashboard/assets/KeysPage-r9rY5EOH.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-_WJKIk68.js
  • src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js
  • src/gateway/static/dashboard/assets/ModelsPage-DH6PpVjl.js
  • src/gateway/static/dashboard/assets/OverviewPage-BE91rAOO.js
  • src/gateway/static/dashboard/assets/OverviewPage-hE_foT76.js
  • src/gateway/static/dashboard/assets/ProvidersPage-C8Yj3DpO.js
  • src/gateway/static/dashboard/assets/ProvidersPage-Yy1MBO6B.js
  • src/gateway/static/dashboard/assets/SettingsPage-D5IbnDQJ.js
  • src/gateway/static/dashboard/assets/Table-CLdjdyTx.js
  • src/gateway/static/dashboard/assets/TablePagination-48YdaA8Y.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CUG7OaE6.js
  • src/gateway/static/dashboard/assets/UsagePage-BSmf3lNS.js
  • src/gateway/static/dashboard/assets/UsagePage-DAwTJ1EE.js
  • src/gateway/static/dashboard/assets/UsersPage-BCuFMIAB.js
  • src/gateway/static/dashboard/assets/UsersPage-D6fvttL7.js
  • src/gateway/static/dashboard/assets/charts-CuOShutR.js
  • src/gateway/static/dashboard/assets/heroui-BX6JwHY-.js
  • src/gateway/static/dashboard/assets/heroui-DZj66Arc.js
  • src/gateway/static/dashboard/assets/index-Ct23mcQF.js
  • src/gateway/static/dashboard/assets/index-DgwFeNFV.js
  • src/gateway/static/dashboard/assets/index-Di8Y5MNG.css
  • src/gateway/static/dashboard/assets/recharts-D7kuKp97.js
  • src/gateway/static/dashboard/assets/tableSelection-CLDlIlLU.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_model_discovery.py
  • tests/integration/test_usage_admin.py
  • tests/unit/test_gateway_model_discovery.py
  • tests/unit/test_model_discovery_env_providers.py
  • web/package.json
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/components/BulkActionBar.test.tsx
  • web/src/components/BulkActionBar.tsx
  • web/src/components/ConfirmDialog.tsx
  • web/src/components/DataTable.test.tsx
  • web/src/components/DataTable.tsx
  • web/src/components/SetPriceDialog.tsx
  • web/src/components/Table.test.tsx
  • web/src/components/Table.tsx
  • web/src/components/TablePagination.test.tsx
  • web/src/components/TablePagination.tsx
  • web/src/lib/tableSelection.test.ts
  • web/src/lib/tableSelection.ts
  • web/src/lib/urlState.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/AliasesPage.test.tsx
  • web/src/pages/AliasesPage.tsx
  • web/src/pages/BudgetsPage.test.tsx
  • web/src/pages/BudgetsPage.tsx
  • web/src/pages/KeysPage.test.tsx
  • web/src/pages/KeysPage.tsx
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/OverviewPage.tsx
  • web/src/pages/ProvidersPage.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
  • web/src/pages/UsersPage.test.tsx
  • web/src/pages/UsersPage.tsx
  • web/src/styles/globals.css
💤 Files with no reviewable changes (15)
  • src/gateway/static/dashboard/assets/KeysPage-r9rY5EOH.js
  • src/gateway/static/dashboard/assets/AliasesPage-BSEuvruJ.js
  • web/src/components/Table.test.tsx
  • src/gateway/static/dashboard/assets/OverviewPage-hE_foT76.js
  • tests/unit/test_model_discovery_env_providers.py
  • src/gateway/static/dashboard/assets/ActivityPage-DLDDQ1lo.js
  • src/gateway/static/dashboard/assets/UsersPage-BCuFMIAB.js
  • src/gateway/static/dashboard/assets/BudgetsPage-CofqydTM.js
  • web/src/components/Table.tsx
  • src/gateway/static/dashboard/assets/ProvidersPage-Yy1MBO6B.js
  • src/gateway/static/dashboard/assets/UsagePage-BSmf3lNS.js
  • src/gateway/static/dashboard/assets/Table-CLdjdyTx.js
  • tests/unit/test_gateway_model_discovery.py
  • src/gateway/static/dashboard/assets/ModelsPage-DH6PpVjl.js
  • src/gateway/static/dashboard/assets/index-DgwFeNFV.js

Comment thread web/src/components/SetPriceDialog.tsx

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a shared, reusable table framework for the admin dashboard (HeroUI/react-aria 기반) and migrates dashboard table pages to it for consistent filtering, pagination, selection, and bulk actions. It also adds master-key usage admin mutations for cleaning up imported usage rows, expands usage filtering, and changes model discovery to only include explicitly configured providers.

Changes:

  • Added shared dashboard table primitives (DataTable, pagination, selection + bulk action bar, confirm + set-price dialogs, URL-synced state helpers) and migrated table pages/tests to use them.
  • Added standalone, master-key-only usage admin endpoints for deleting and manually repricing imported usage, plus new usage filters (priced, counts_toward_budget) on list/count (and intended for summary/export).
  • Scoped model discovery to configured providers only, removing env-var-only provider discovery behavior and adjusting tests accordingly.

Reviewed changes

Copilot reviewed 62 out of 78 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
web/src/styles/globals.css Adds global styling overrides for the shared HeroUI table container (.otari-table).
web/src/pages/UsersPage.test.tsx Updates interaction expectations to use row “Edit” actions instead of row-click.
web/src/pages/UsagePage.test.tsx Adds bulk-delete test coverage for imported usage rows selection flow.
web/src/pages/ProvidersPage.tsx Migrates Providers table to DataTable columns/rows API.
web/src/pages/OverviewPage.tsx Migrates “Recent activity” table to DataTable.
web/src/pages/ModelsPage.test.tsx Adjusts roles (grid/columnheader) for react-aria table, adds bulk set-pricing test.
web/src/pages/KeysPage.test.tsx Updates tests to open edit via row “Edit” action.
web/src/pages/BudgetsPage.tsx Migrates Budgets table to DataTable + selection + bulk delete confirm dialog.
web/src/pages/BudgetsPage.test.tsx Updates edit-open behavior to row “Edit” action.
web/src/pages/AliasesPage.tsx Migrates Aliases table to DataTable + selection + bulk delete confirm dialog; disables selection for config aliases.
web/src/pages/AliasesPage.test.tsx Adds coverage for disabled selection of config aliases and bulk delete of stored aliases.
web/src/lib/urlState.ts New URL query-state helper hook for table filters/pagination (contains a numeric fallback bug noted in comments).
web/src/lib/tableSelection.ts New shared selection state helper (allMatching intent + id resolution utilities).
web/src/lib/tableSelection.test.ts Unit tests for selection helpers.
web/src/components/TablePagination.tsx New shared pagination control (rows-per-page + jump/typed page).
web/src/components/TablePagination.test.tsx Tests for pagination behavior (range summary, clamping, unknown totals).
web/src/components/Table.tsx Removes legacy bespoke <table> implementation and column-resize code.
web/src/components/Table.test.tsx Removes tests for the deleted bespoke table/resize behavior.
web/src/components/SetPriceDialog.tsx New dialog for manual per-1M rate input for repricing imported usage.
web/src/components/DataTable.tsx New shared table component built on HeroUI/react-aria, with selection/sort/resize support.
web/src/components/DataTable.test.tsx Tests for shared DataTable rendering, selection, disabled rows, sort events.
web/src/components/ConfirmDialog.tsx New controlled confirm dialog for destructive bulk actions.
web/src/components/BulkActionBar.tsx New contextual bulk action bar (incl. “select all matching” affordance).
web/src/components/BulkActionBar.test.tsx Tests for bulk action bar labels and select-all affordance.
web/src/api/types.ts Extends usage filters/types and adds usage bulk mutation request/response types.
web/src/api/hooks.ts Adds usage delete/set-price mutations; extends usage params serialization; adds optional enable flag for usage count.
web/package.json Adds react-aria-components dependency.
web/package-lock.json Locks the new react-aria-components dependency.
tests/unit/test_model_discovery_env_providers.py Removes unit tests for env-var-only provider discovery (behavior removed).
tests/unit/test_gateway_model_discovery.py Removes env-provider stubbing fixture now that discovery is configured-only.
tests/integration/test_model_discovery.py Updates integration expectation to exclude env-only providers from discovery.
src/gateway/static/dashboard/index.html Updates bundled dashboard entrypoint asset hashes.
src/gateway/static/dashboard/assets/UsersPage-D6fvttL7.js Updated bundled dashboard asset (generated).
src/gateway/static/dashboard/assets/UsersPage-BCuFMIAB.js Removed old bundled dashboard asset (generated).
src/gateway/static/dashboard/assets/UsagePage-BSmf3lNS.js Removed old bundled dashboard asset (generated).
src/gateway/static/dashboard/assets/tableSelection-CLDlIlLU.js Added bundled selection helper asset (generated).
src/gateway/static/dashboard/assets/TablePagination-48YdaA8Y.js Added bundled pagination/set-price dialog asset (generated).
src/gateway/static/dashboard/assets/Table-CLdjdyTx.js Removed old bundled table asset (generated).
src/gateway/static/dashboard/assets/OverviewPage-hE_foT76.js Removed old bundled overview asset (generated).
src/gateway/static/dashboard/assets/OverviewPage-BE91rAOO.js Added updated bundled overview asset (generated).
src/gateway/static/dashboard/assets/ModelScopeControl-_WJKIk68.js Updated bundled model-scope control asset (generated).
src/gateway/static/dashboard/assets/Field-DEJ6Wjg9.js Updated bundled field asset (generated).
src/gateway/static/dashboard/assets/DataTable-mgItq60H.js Added bundled DataTable asset (generated).
src/gateway/static/dashboard/assets/ConfirmDialog-DFHlLPE5.js Added bundled ConfirmDialog asset (generated).
src/gateway/static/dashboard/assets/charts-CuOShutR.js Updated bundled charts asset (generated).
src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js Added updated bundled aliases page asset (generated).
src/gateway/static/dashboard/assets/AliasesPage-BSEuvruJ.js Removed old bundled aliases page asset (generated).
src/gateway/static/dashboard/assets/ActivityPage-DLDDQ1lo.js Removed old bundled activity page asset (generated).
src/gateway/services/usage_admin_service.py New backend service for imported-usage delete and manual repricing (has selection scoping and performance concerns noted in comments).
src/gateway/services/model_discovery_service.py Scopes model discovery to configured providers only (removes env-var-only discovery).
src/gateway/api/routes/usage.py Adds usage list/count filters (priced, counts_toward_budget) and new admin mutation endpoints; summary/export missing counts_toward_budget propagation (noted in comments).
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json Erroneously committed local Vitest artifact (should be removed).
Files not reviewed (12)
  • src/gateway/static/dashboard/assets/ActivityPage-BbcN3Ifw.js: Generated file
  • src/gateway/static/dashboard/assets/AliasesPage-D8LNylbG.js: Generated file
  • src/gateway/static/dashboard/assets/BudgetsPage-ewpsdquY.js: Generated file
  • src/gateway/static/dashboard/assets/ConfirmDialog-DFHlLPE5.js: Generated file
  • src/gateway/static/dashboard/assets/DataTable-mgItq60H.js: Generated file
  • src/gateway/static/dashboard/assets/KeysPage-B1eIYMxz.js: Generated file
  • src/gateway/static/dashboard/assets/ModelsPage-CnLV1KJB.js: Generated file
  • src/gateway/static/dashboard/assets/OverviewPage-BE91rAOO.js: Generated file
  • src/gateway/static/dashboard/assets/ProvidersPage-C8Yj3DpO.js: Generated file
  • src/gateway/static/dashboard/assets/TablePagination-48YdaA8Y.js: Generated file
  • src/gateway/static/dashboard/assets/UsagePage-DAwTJ1EE.js: Generated file
  • web/package-lock.json: Generated file
Comments suppressed due to low confidence (5)

src/gateway/api/routes/usage.py:567

  • _summary_context builds its conditions via _usage_filters but does not pass through counts_toward_budget, so summary endpoints cannot match list/count filtering for imported vs enforced rows. Pass counts_toward_budget=counts_toward_budget into _usage_filters(...).
    conditions = _usage_filters(
        start_date=start,
        end_date=end,
        user_id=user_id,
        status=status,
        model=model,
        endpoint=endpoint,
        source=source,
        api_key_id=api_key_id,
        priced=priced,
    )

src/gateway/api/routes/usage.py:646

  • After adding counts_toward_budget to /summary, it also needs to be forwarded into _summary_context(...) so the filter actually applies (and matches list/count semantics).
    start, end, conditions, totals = await _summary_context(
        db,
        start_date=start_date,
        end_date=end_date,
        user_id=user_id,
        status=status,
        model=model,
        endpoint=endpoint,
        source=source,
        api_key_id=api_key_id,
        priced=priced,
    )

src/gateway/api/routes/usage.py:706

  • /v1/usage/summary.csv is also missing counts_toward_budget, so CSV exports cannot match the filtered view when scoping to imported vs enforced rows. Add the param and pass it through to _summary_context like /summary does.
    model: str | None = Query(default=None, description=_MODEL_DESC),
    endpoint: str | None = Query(default=None, description=_ENDPOINT_DESC),
    source: str | None = Query(default=None, description=_SOURCE_DESC),
    api_key_id: str | None = Query(default=None, description=_API_KEY_DESC),
    priced: bool | None = Query(default=None, description=_PRICED_DESC),
) -> Response:

src/gateway/api/routes/usage.py:725

  • After adding counts_toward_budget to /summary.csv, ensure it is passed through to _summary_context(...) so the export uses the same filter conditions as the table view.
    _start, _end, conditions, totals = await _summary_context(
        db,
        start_date=start_date,
        end_date=end_date,
        user_id=user_id,
        status=status,
        model=model,
        endpoint=endpoint,
        source=source,
        api_key_id=api_key_id,
        priced=priced,
    )

src/gateway/api/routes/usage.py:626

  • /v1/usage/summary is missing a counts_toward_budget query param, so the summary cannot be filtered to imported vs enforced rows even though list/count can. This also means the dashboard will send an unused query param (via usageParams) and show inconsistent totals vs the table.
    endpoint: str | None = Query(default=None, description=_ENDPOINT_DESC),
    source: str | None = Query(default=None, description=_SOURCE_DESC),
    api_key_id: str | None = Query(default=None, description=_API_KEY_DESC),
    priced: bool | None = Query(default=None, description=_PRICED_DESC),
    bucket: Bucket = Query(default="day", description="Time-series granularity: 'hour' or 'day'"),

Comment thread web/src/lib/urlState.ts
Comment thread src/gateway/services/usage_admin_service.py Outdated
Comment thread src/gateway/api/routes/usage.py
Comment thread node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json Outdated
Comment thread src/gateway/services/usage_admin_service.py

@khaledosman khaledosman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared table framework + usage-admin endpoints. The imported-only scoping is solid (server pins counts_toward_budget = False as a fixed WHERE clause and the frontend intersects the react-aria selection with selectableKeys — defense-in-depth on a destructive path), and test coverage is thorough. Inline notes below; none blocking.

🤖 Review by Claude Code

Comment thread node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json Outdated
Comment thread src/gateway/services/usage_admin_service.py
Comment thread src/gateway/services/usage_admin_service.py Outdated
Comment thread web/src/lib/urlState.ts
njbrake and others added 3 commits July 24, 2026 12:18
Backend:
- Scope the usage delete / set-price ops to imported rows explicitly with
  `source != "gateway"`, not just `counts_toward_budget = False`: budget-exempt
  gateway traffic is also `counts_toward_budget = False`, and the ops must never
  touch real gateway rows (Copilot). Adds a regression test.
- Make set-price atomic and memory-bounded: keyset-paginate the matched rows and
  commit once at the end (like delete), instead of loading all ids into memory
  and committing per chunk (khaledosman, Copilot).
- Add `endpoint` to the selection model so its filter set matches `_usage_filters`
  and cannot drift from the count (khaledosman).
- Accept `counts_toward_budget` on the usage summary / csv endpoints so the
  dashboard's summary can't disagree with the list/count (Copilot). Regenerates
  the OpenAPI spec.

Frontend:
- `useUrlState.getNumber` falls back to the key's default (not 0) on a non-numeric
  param, so a hand-edited `?size=abc` no longer sends `limit=0` (Copilot,
  khaledosman, CodeRabbit). Adds urlState tests.
- Reset SetPriceDialog and the assign-budget picker when they open, so a reopen
  for a different selection does not inherit stale values (CodeRabbit).
- Restore the mobile "Filters" collapse on Activity and Usage that the rewrite
  dropped: the filter controls collapse behind a toggle (labelled with the active
  count) below the md breakpoint.
- Add ConfirmDialog tests and bulk-action tests for Budgets and Users.

Repo hygiene:
- Stop tracking a stray `node_modules/.vite` vitest cache accidentally committed
  from running npx at the repo root, and gitignore root `node_modules/`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mework

# Conflicts:
#	src/gateway/static/dashboard/assets/AliasesPage-BqLd7LKr.js
#	src/gateway/static/dashboard/assets/KeysPage-BfiWORhW.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-BOaD0Hrh.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-DGhOUPpS.js
#	src/gateway/static/dashboard/assets/ModelScopeControl-yafJYqc3.js
#	src/gateway/static/dashboard/assets/ModelsPage-DAJitSnp.js
#	src/gateway/static/dashboard/assets/OverviewPage-D-4Und7I.js
#	src/gateway/static/dashboard/assets/ProvidersPage-ZF6ihggi.js
#	src/gateway/static/dashboard/assets/SettingsPage-C8J8TlCb.js
#	src/gateway/static/dashboard/assets/SettingsPage-CWqsNLqk.js
#	src/gateway/static/dashboard/assets/SettingsPage-Dgt6GE5d.js
#	src/gateway/static/dashboard/assets/ToolsGuardrailsPage-Bz8EgeXY.js
#	src/gateway/static/dashboard/assets/UsersPage-28zvfI6e.js
#	src/gateway/static/dashboard/assets/index-BFG8RNrJ.css
#	src/gateway/static/dashboard/assets/index-CCVn0vfk.css
#	src/gateway/static/dashboard/assets/index-DoDRzM88.css
#	src/gateway/static/dashboard/index.html
#	web/src/pages/ActivityPage.tsx
#	web/src/pages/BudgetsPage.tsx
#	web/src/pages/UsagePage.test.tsx
#	web/src/pages/UsagePage.tsx
Mirrors the Activity page: a "Custom…" button reveals from/to datetime inputs
that set an explicit start_date/end_date window (bucketed daily) and override the
preset window. The period-over-period delta compares the immediately preceding
window of equal length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 24, 2026 12:36 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from khaledosman July 24, 2026 12:37
@njbrake

njbrake commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Note: this comment was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

Thanks for the reviews. All the feedback is addressed (pushed in the last few commits), the branch is merged up to latest main, and the pages are re-verified in a browser on mobile. Rundown:

Correctness / safety

  • Ops could touch budget-exempt gateway rows (Copilot): the delete / set-price selection is now scoped with source != "gateway" on top of counts_toward_budget = False, so gateway traffic on a budget-exempt key is never a target. Added a regression test.
  • set-price memory and atomicity (Copilot, khaledosman): it now keyset-paginates the matched rows (bounded memory) and commits once at the end, so it is all-or-nothing like delete rather than committing per chunk.
  • Summary could disagree with list/count (Copilot): /v1/usage/summary and the CSV export now accept counts_toward_budget, threaded through _summary_context.
  • urlState.getNumber returned 0 on a non-numeric param (Copilot, khaledosman, CodeRabbit): it now falls back to the key's default, so a hand-edited ?size=abc cannot send limit=0. Added tests.
  • Selection field drift (khaledosman): added endpoint to the selection model so its filter set matches _usage_filters.

Frontend

  • Dialogs kept stale form state on reopen (CodeRabbit): SetPriceDialog and the assign-budget picker reset when they open.
  • Added ConfirmDialog tests plus bulk-action tests for Budgets and Users.

Hygiene

  • Committed vitest cache artifact (Copilot, khaledosman): removed node_modules/.vite/... from tracking and gitignored root node_modules/.

Also folded in the follow-up requests: the branch is merged up to main (so the mobile nav drawer from #406 is back), the Activity and Usage filter controls collapse behind a mobile "Filters" toggle again, and the Usage page gained a custom from/to time range like Activity.

Resolving the threads now.

njbrake and others added 2 commits July 24, 2026 12:40
…ints

The bulk delete / set-price endpoints and the priced / counts_toward_budget
filters changed the API surface; regenerate the committed Postman collection to
match (make postman-check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared table renders on react-aria, so a data cell is a `gridcell` and the
row-header column is a `rowheader` (not `cell`). Updates the create-budget and
create-alias assertions accordingly. Verified locally: 7 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests July 24, 2026 12:42 — with GitHub Actions Inactive
@njbrake
njbrake merged commit c8e09b3 into main Jul 24, 2026
14 checks passed
@njbrake
njbrake deleted the feat/shared-table-framework branch July 24, 2026 12:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
web/src/pages/UsagePage.tsx (1)

526-535: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

drillTo ignores the custom range window.

drillTo seeds start_date from startDate (the preset-only state), not winStart. When customMode is active, the actual filtering window comes from customFrom/winStart, but a click into a breakdown row will still carry over whatever startDate happened to be last set to (from before switching to custom mode, or the default preset) — so the Activity page opens with the wrong time window relative to what the user is currently looking at.

🔗 Suggested fix: drill using the active window, not the stale preset state
   const drillTo = (params: Record<string, string | undefined>) => {
     const search = new URLSearchParams();
-    if (startDate) search.set("start_date", startDate);
+    if (winStart) search.set("start_date", winStart);
+    if (winEnd) search.set("end_date", winEnd);
     for (const [k, v] of Object.entries(params)) {
       if (v) search.set(k, v);
     }
     navigate(`/activity?${search.toString()}`);
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/UsagePage.tsx` around lines 526 - 535, Update drillTo to seed
the Activity navigation query from the active window value winStart rather than
the stale preset-only startDate state, while preserving the existing breakdown
parameter handling and navigation behavior.
src/gateway/api/routes/usage.py (1)

174-179: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Add coverage for cost and counts_toward_budget filters.

usage_logs currently indexes FKs, user_id/timestamp, status/timestamp, and source, but not the new public filters: cost IS NULL / cost IS NOT NULL and counts_toward_budget = TRUE/FALSE. Add indexes or partial indexes for these columns, especially if filtering on counts_toward_budget=False also drives selective bulk-delete/reprice operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/usage.py` around lines 174 - 179, Add database indexes
for the public filters implemented in the usage query around priced and
counts_toward_budget, covering cost NULL/non-NULL checks and
counts_toward_budget boolean equality; prefer partial indexes where appropriate,
especially for counts_toward_budget=False bulk-delete/reprice queries, and
include the changes in the relevant schema migration/model index definitions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/gateway/static/dashboard/assets/ActivityPage-DyhDMr6Q.js`:
- Line 1: Update the URL-state number parsing used by qe, specifically
getNumber, to apply per-key bounds defined in web/src/lib/urlState.ts so invalid
page and size values such as zero or negatives fall back to their defaults.
Preserve valid URL values, then regenerate the ActivityPage asset and extend the
fallback tests to cover zero and negative inputs.

In `@src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js`:
- Line 1: Update the bulk mutation handlers so partial failures retain only
failed targets, preventing retries from repeating successful operations: in
src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js:1-1, adjust Oe’s ee
delete flow to track rejected budget IDs and remove successful IDs from f
selection before surfacing errors; apply the same successful-target removal in
src/gateway/static/dashboard/assets/AliasesPage-B5d3dytf.js:1-1 for its bulk
alias deletion handler and in
src/gateway/static/dashboard/assets/ModelsPage-BS-xT7Ts.js:1-1 for its bulk
repricing handler. Clear the selection only when all targets succeed.

In `@web/src/pages/UsagePage.tsx`:
- Around line 421-444: Update the custom date range handling in UsagePage around
customFrom, customTo, winStart, and winEnd so datetime-local selections are
explicitly labeled as UTC or converted to UTC ISO strings before populating
UsageFilters.start_date and end_date; preserve the existing preset behavior and
ensure the chart query window matches the displayed UTC semantics.

---

Outside diff comments:
In `@src/gateway/api/routes/usage.py`:
- Around line 174-179: Add database indexes for the public filters implemented
in the usage query around priced and counts_toward_budget, covering cost
NULL/non-NULL checks and counts_toward_budget boolean equality; prefer partial
indexes where appropriate, especially for counts_toward_budget=False
bulk-delete/reprice queries, and include the changes in the relevant schema
migration/model index definitions.

In `@web/src/pages/UsagePage.tsx`:
- Around line 526-535: Update drillTo to seed the Activity navigation query from
the active window value winStart rather than the stale preset-only startDate
state, while preserving the existing breakdown parameter handling and navigation
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f021f86a-f952-4d83-9929-e61c98408c92

📥 Commits

Reviewing files that changed from the base of the PR and between 9961296 and 2bb99e8.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (35)
  • .gitignore
  • docs/public/otari.postman_collection.json
  • src/gateway/api/routes/usage.py
  • src/gateway/services/usage_admin_service.py
  • src/gateway/static/dashboard/assets/ActivityPage-DyhDMr6Q.js
  • src/gateway/static/dashboard/assets/AliasesPage-B5d3dytf.js
  • src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-CSBsET-p.js
  • src/gateway/static/dashboard/assets/KeysPage-BiO9tu08.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DPtZaEWl.js
  • src/gateway/static/dashboard/assets/ModelsPage-BS-xT7Ts.js
  • src/gateway/static/dashboard/assets/OverviewPage-BINTdHsm.js
  • src/gateway/static/dashboard/assets/ProvidersPage-BKJfR2nH.js
  • src/gateway/static/dashboard/assets/SettingsPage-DPkAq-xL.js
  • src/gateway/static/dashboard/assets/TablePagination-CsvD577_.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-jvTZg2ew.js
  • src/gateway/static/dashboard/assets/UsagePage-B6UkNWAG.js
  • src/gateway/static/dashboard/assets/UsersPage-Cz68J9bP.js
  • src/gateway/static/dashboard/assets/index-D1O_nkKy.js
  • src/gateway/static/dashboard/assets/index-pOfhHB4P.css
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_usage_admin.py
  • web/e2e/dashboard.spec.ts
  • web/src/components/ConfirmDialog.test.tsx
  • web/src/components/SetPriceDialog.tsx
  • web/src/lib/urlState.test.tsx
  • web/src/lib/urlState.ts
  • web/src/pages/ActivityPage.test.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/BudgetsPage.test.tsx
  • web/src/pages/BudgetsPage.tsx
  • web/src/pages/UsagePage.test.tsx
  • web/src/pages/UsagePage.tsx
  • web/src/pages/UsersPage.test.tsx
  • web/src/pages/UsersPage.tsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • web/src/pages/UsagePage.test.tsx
  • web/src/lib/urlState.ts
  • web/src/pages/UsersPage.tsx
  • web/src/pages/BudgetsPage.tsx
  • web/src/components/SetPriceDialog.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/ActivityPage.test.tsx
  • tests/integration/test_usage_admin.py

@@ -0,0 +1 @@
import{j as t}from"./tanstack-query-1t81HyiD.js";import{B as _,c as Ce,d as ne}from"./heroui-DZj66Arc.js";import{u as Pe,r as n}from"./react-dgEcD0HR.js";import{u as Ae,a as De,b as Fe,c as oe,d as Ee,e as Me,f as Oe,P as Te,E as Ue,F as ie,g as R}from"./index-D1O_nkKy.js";import{u as Ie,r as Le,B as Re}from"./tableSelection-CLDlIlLU.js";import{C as $e}from"./ConfirmDialog-CSBsET-p.js";import{D as Be}from"./DataTable-mgItq60H.js";import{T as ze,S as Ke}from"./TablePagination-CsvD577_.js";function qe(a){const[l,r]=Pe(),p=n.useCallback(i=>l.get(i)??a[i],[l,a]),s=n.useCallback(i=>{const h=Number.parseInt(l.get(i)??"",10);if(!Number.isNaN(h))return h;const c=Number.parseInt(a[i],10);return Number.isNaN(c)?0:c},[l,a]),g=n.useCallback(i=>{r(h=>{const c=new URLSearchParams(h);for(const[u,f]of Object.entries(i)){const x=String(f);x===""||x===a[u]?c.delete(u):c.set(u,x)}return c},{replace:!0})},[r,a]);return{get:p,getNumber:s,patch:g}}const Ge=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:4});function D(a){return a===null?"—":Ge.format(a)}function j(a){return a===null?"—":a.toLocaleString()}function ce(a){return a===null?"—":a<1e3?`${a} ms`:`${(a/1e3).toFixed(a<1e4?2:1)} s`}function He(a){const l=new Date(a);return Number.isNaN(l.getTime())?a:l.toLocaleString()}function We(a){const l=new Date(a).getTime();if(Number.isNaN(l))return a;const r=Math.max(0,Math.round((Date.now()-l)/1e3));if(r<60)return`${r}s ago`;const p=Math.round(r/60);if(p<60)return`${p}m ago`;const s=Math.round(p/60);return s<24?`${s}h ago`:`${Math.round(s/24)}d ago`}const Je=3600,$=86400,z=[{key:"1h",label:"1h",seconds:Je},{key:"24h",label:"24h",seconds:$},{key:"7d",label:"7d",seconds:7*$},{key:"30d",label:"30d",seconds:30*$},{key:"all",label:"All",seconds:null}],de="24h",Ye=[{label:"All",value:""},{label:"Success",value:"success"},{label:"Error",value:"error"}],Ze=[{label:"All",value:""},{label:"Priced",value:"true"},{label:"Unpriced",value:"false"}],Qe=50,Ve={range:de,start_date:"",end_date:"",status:"",model:"",user_id:"",api_key_id:"",priced:"",page:"0",size:String(Qe)};function Xe(a){return new Date(Date.now()-a*1e3).toISOString()}function B(a,l,r){if(l||r)return{start:l||void 0,end:r||void 0};if(a==="custom")return{};const p=z.find(g=>g.key===a)??z.find(g=>g.key===de),s=(p==null?void 0:p.seconds)??null;return{start:s==null?void 0:Xe(s),end:void 0}}function et({status:a}){const l=a==="error"?"border-red-200 bg-red-50 text-red-700":"border-[var(--otari-line)] bg-[var(--otari-brand-tint)] text-[var(--otari-brand-dark)]";return t.jsx("span",{className:`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${l}`,children:a})}const tt={gateway:"Gateway",claude_code:"Claude Code",codex:"Codex"};function at(a){return tt[a]??a}function o({label:a,children:l}){return t.jsxs("div",{className:"flex flex-col gap-0.5",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:a}),t.jsx("span",{className:"text-sm text-[var(--otari-ink)] break-all",children:l})]})}function st({entry:a}){var l;return t.jsxs("div",{className:"flex flex-col gap-4 px-4 py-4",children:[a.error_message?t.jsxs("div",{className:"flex flex-col gap-1.5",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Error"}),t.jsx("pre",{className:"max-h-48 overflow-auto rounded-lg border border-red-200 bg-red-50 p-3 text-xs whitespace-pre-wrap break-all text-red-700",children:"The provider returned an error. Inspect gateway logs for details."})]}):null,t.jsxs("div",{className:"grid gap-4 sm:grid-cols-2 lg:grid-cols-4",children:[t.jsx(o,{label:"Provider",children:a.provider??"—"}),t.jsx(o,{label:"Endpoint",children:a.endpoint}),t.jsx(o,{label:"Source",children:at(a.source)}),a.source_label?t.jsx(o,{label:"Session",children:a.source_label}):null,t.jsx(o,{label:"User",children:a.user_id??"—"}),t.jsx(o,{label:"API key",children:a.api_key_id??"—"}),t.jsx(o,{label:"Prompt tokens",children:j(a.prompt_tokens)}),t.jsx(o,{label:"Completion tokens",children:j(a.completion_tokens)}),t.jsx(o,{label:"Total tokens",children:j(a.total_tokens)}),t.jsx(o,{label:"Cost",children:D(a.cost)}),t.jsx(o,{label:"Cache read tokens",children:j(a.cache_read_tokens)}),t.jsx(o,{label:"Cache write tokens",children:j(a.cache_write_tokens)}),t.jsx(o,{label:"1h cache writes",children:j(a.cache_write_1h_tokens??null)}),t.jsx(o,{label:"Total time",children:ce(a.latency_ms)}),t.jsx(o,{label:"Request ID",children:a.id})]}),(l=a.pricing_breakdown)!=null&&l.length?t.jsxs("div",{className:"flex flex-col gap-2",children:[t.jsx("span",{className:"text-[11px] font-medium uppercase tracking-wide text-[var(--otari-muted)]",children:"Billed meters"}),t.jsx("div",{className:"grid gap-2 sm:grid-cols-2 lg:grid-cols-3",children:a.pricing_breakdown.map(r=>t.jsxs(o,{label:r.meter.replaceAll("_"," "),children:[j(r.units)," at ",D(r.rate_per_million)," / 1M, ",D(r.cost)]},r.meter))})]}):null]})}function ht(){var ae,se,le,re;const a=Ae(),l=De(),r=n.useMemo(()=>{const e=new Map;for(const w of l.data??[])e.set(w.id,w.key_name??`${w.id.slice(0,8)}…`);return e},[l.data]),p=e=>e===null?"—":r.get(e)??`${e.slice(0,8)}…`,s=qe(Ve),g=s.get("range"),i=s.get("start_date"),h=s.get("end_date"),c=s.get("status"),u=s.get("model"),f=s.get("user_id"),x=s.get("api_key_id"),k=s.get("priced"),K=s.getNumber("page"),F=s.getNumber("size"),[b,q]=n.useState(()=>B(g,i,h));n.useEffect(()=>{q(B(g,i,h))},[g,i,h]);const E=i||h?"custom":g,ue=!!(b.start||b.end),G=k==="true"?!0:k==="false"?!1:void 0,m=n.useMemo(()=>({start_date:b.start,end_date:b.end,status:c||void 0,model:u.trim()||void 0,user_id:f||void 0,api_key_id:x||void 0,priced:G}),[b,c,u,f,x,G]),d=Ie(),C=JSON.stringify(m),H=n.useRef(C);n.useEffect(()=>{H.current!==C&&(H.current=C,s.patch({page:0}),d.clear())},[C,s,d]);const y=Fe(m,K,F),S=oe(m),me=n.useMemo(()=>({start_date:b.start,end_date:b.end,status:c||void 0,user_id:f||void 0,api_key_id:x||void 0}),[b,c,f,x]),M=((se=(ae=Ee(me,"day").data)==null?void 0:ae.by_model)==null?void 0:se.filter(e=>!e.is_other&&e.key!==null).map(e=>e.key))??[],pe=(l.data??[]).map(e=>({value:e.id,label:e.key_name??`${e.id.slice(0,8)}…`})),v=y.data??[],ge=S.isSuccess&&!S.isPlaceholderData?((le=S.data)==null?void 0:le.total)??0:null,W=!!(c||u.trim()||f||x||k||ue),[J,he]=n.useState(!1),Y=[c,k,x,f,u.trim()].filter(Boolean).length,O=n.useMemo(()=>v.filter(e=>!e.counts_toward_budget).map(e=>e.id),[v]),xe=n.useMemo(()=>v.filter(e=>e.counts_toward_budget).map(e=>e.id),[v]),Z=Le(d.selectedKeys,O),N=Z.length,Q=d.allMatching||N>0,fe=n.useMemo(()=>({...m,counts_toward_budget:!1}),[m]),V=oe(fe,Q),P=V.isSuccess?((re=V.data)==null?void 0:re.total)??null:null,be=O.length>0&&N===O.length&&P!=null&&P>N,A=d.allMatching?P??N:N,T=Me(),U=Oe(),[ve,I]=n.useState(!1),[_e,L]=n.useState(!1),[je,X]=n.useState(null),ee=v.find(e=>e.id===je)??null,te=()=>d.allMatching?{by_filter:!0,model:m.model,user_id:m.user_id,api_key_id:m.api_key_id,status:m.status,start_date:m.start_date,end_date:m.end_date,priced:m.priced}:{ids:Z},ye=()=>{T.mutate(te(),{onSuccess:()=>{I(!1),d.clear()}})},ke=e=>{U.mutate({...te(),...e},{onSuccess:()=>{L(!1),d.clear()}})},Se=()=>{s.patch({range:"all",start_date:"",end_date:"",status:"",model:"",user_id:"",api_key_id:"",priced:""})},Ne=()=>{q(B(g,i,h)),y.refetch(),S.refetch()},we=[{id:"time",header:"Time",cell:e=>t.jsx("span",{title:He(e.timestamp),className:"text-[var(--otari-muted)]",children:We(e.timestamp)})},{id:"user",header:"User",cell:e=>e.user_id??"—"},{id:"model",header:"Model",isRowHeader:!0,cell:e=>e.model},{id:"api_key",header:"API key",cell:e=>t.jsx("span",{className:"text-[var(--otari-muted)]",children:p(e.api_key_id)})},{id:"tokens",header:"Tokens",align:"end",cell:e=>j(e.total_tokens)},{id:"cost",header:"Cost",align:"end",cell:e=>D(e.cost)},{id:"latency",header:"Total time",align:"end",cell:e=>ce(e.latency_ms)},{id:"status",header:"Status",cell:e=>t.jsx(et,{status:e.status})}];return t.jsxs("div",{className:"flex flex-col gap-6",children:[t.jsx(Te,{title:"Activity",description:"A per-request log of what the gateway served: tokens, cost, latency, and failures. No request or response content is stored.",action:t.jsx(_,{variant:"outline",onPress:Ne,isDisabled:y.isFetching,children:"Refresh"})}),t.jsx(Ue,{error:y.error??S.error}),t.jsxs("div",{className:"flex flex-wrap items-end gap-3",children:[t.jsxs("div",{className:"flex flex-wrap gap-2",children:[z.map(e=>t.jsx(_,{size:"sm",variant:E===e.key?"primary":"outline",onPress:()=>s.patch({range:e.key,start_date:"",end_date:""}),children:e.label},e.key)),t.jsx(_,{size:"sm",variant:E==="custom"?"primary":"outline",onPress:()=>s.patch({range:"custom"}),children:"Custom…"})]}),E==="custom"?t.jsxs("div",{className:"flex flex-wrap items-end gap-2",children:[t.jsxs("label",{className:"flex flex-col gap-1 text-xs font-medium text-[var(--otari-muted)]",children:["From",t.jsx("input",{type:"datetime-local",value:i,onChange:e=>s.patch({start_date:e.target.value}),className:"rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none"})]}),t.jsxs("label",{className:"flex flex-col gap-1 text-xs font-medium text-[var(--otari-muted)]",children:["To",t.jsx("input",{type:"datetime-local",value:h,onChange:e=>s.patch({end_date:e.target.value}),className:"rounded-lg border border-[var(--otari-line)] bg-[var(--otari-bg)] px-3 py-2 text-sm text-[var(--otari-ink)] focus:border-[var(--otari-brand)] focus:outline-none"})]})]}):null,t.jsxs(_,{size:"sm",variant:"outline",className:"md:hidden",onPress:()=>he(e=>!e),"aria-expanded":J,"aria-controls":"activity-filters",children:["Filters",Y?` (${Y})`:""]}),t.jsxs("div",{id:"activity-filters",className:Ce("flex-wrap items-end gap-3 md:flex",J?"flex w-full md:w-auto":"hidden"),children:[t.jsx(ie,{id:"filter-status",label:"Status",value:c,onChange:e=>s.patch({status:e}),children:Ye.map(e=>t.jsx("option",{value:e.value,children:e.label},e.value))}),t.jsx(ie,{id:"filter-priced",label:"Priced?",value:k,onChange:e=>s.patch({priced:e}),children:Ze.map(e=>t.jsx("option",{value:e.value,children:e.label},e.value))}),t.jsx(R,{label:"API key",value:x,onChange:e=>s.patch({api_key_id:e}),placeholder:"All keys",options:pe}),t.jsx(R,{label:"User",value:f,onChange:e=>s.patch({user_id:e}),placeholder:"All users",options:(a.data??[]).map(e=>({value:e.user_id,label:e.alias?`${e.alias} (${e.user_id})`:e.user_id}))}),t.jsx(R,{label:"Model",value:u,onChange:e=>s.patch({model:e}),allowsCustom:!0,placeholder:"Any model",options:(u&&!M.includes(u)?[u,...M]:M).map(e=>({value:e,label:e}))}),W?t.jsx(_,{size:"sm",variant:"ghost",onPress:Se,children:"Clear filters"}):null]})]}),Q?t.jsxs(Re,{selectedCount:A,allMatching:d.allMatching,matchingTotal:P,canSelectAllMatching:be,onSelectAllMatching:d.enableAllMatching,onClear:d.clear,children:[t.jsx(_,{size:"sm",variant:"primary",onPress:()=>L(!0),children:"Set price"}),t.jsx(_,{size:"sm",variant:"danger",onPress:()=>I(!0),children:"Delete"})]}):null,t.jsx(Be,{ariaLabel:"Activity log",columns:we,rows:v,getRowKey:e=>e.id,isLoading:y.isLoading,emptyContent:W?"No requests match these filters.":"No requests recorded yet.",selectionMode:"multiple",selectedKeys:d.selectedKeys,onSelectionChange:d.onSelectionChange,disabledKeys:xe,onRowAction:e=>X(w=>w===e?null:e),rowClassName:e=>e.status==="error"?"bg-red-50":void 0}),ee?t.jsx(ne,{children:t.jsxs(ne.Content,{className:"p-0",children:[t.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[t.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Request detail"}),t.jsx(_,{size:"sm",variant:"ghost",onPress:()=>X(null),children:"Close"})]}),t.jsx(st,{entry:ee})]})}):null,t.jsx(ze,{page:K,pageSize:F,total:ge,rowsOnPage:v.length,onPageChange:e=>s.patch({page:e}),onPageSizeChange:e=>s.patch({size:e,page:0}),isFetching:y.isFetching,hasNextFallback:v.length===F}),t.jsx($e,{isOpen:ve,onOpenChange:I,heading:"Delete usage rows",body:`Delete ${A.toLocaleString()} imported ${A===1?"row":"rows"}? Only imported rows are removed, and this cannot be undone.`,confirmLabel:"Delete",isPending:T.isPending,error:T.error,onConfirm:ye}),t.jsx(Ke,{isOpen:_e,onOpenChange:L,targetCount:A,isPending:U.isPending,error:U.error,onSubmit:ke})]})}export{ht as ActivityPage};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject out-of-range pagination values from URL state.

getNumber only checks for NaN. Values such as ?size=0, ?size=-1, or ?page=-1 pass through to the usage query and can produce invalid limits or negative offsets. Add per-key bounds in web/src/lib/urlState.ts, then regenerate this asset. The fallback test should also cover zero and negative values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/static/dashboard/assets/ActivityPage-DyhDMr6Q.js` at line 1,
Update the URL-state number parsing used by qe, specifically getNumber, to apply
per-key bounds defined in web/src/lib/urlState.ts so invalid page and size
values such as zero or negatives fall back to their defaults. Preserve valid URL
values, then regenerate the ActivityPage asset and extend the fallback tests to
cover zero and negative inputs.

@@ -0,0 +1 @@
import{j as e}from"./tanstack-query-1t81HyiD.js";import{r as o}from"./react-dgEcD0HR.js";import{l as re,u as ae,m as le,n as ie,o as oe,p as de,P as ue,E as O,I as ce,q as me}from"./index-D1O_nkKy.js";import{u as xe,r as ge,B as he}from"./tableSelection-CLDlIlLU.js";import{C as pe}from"./ConfirmDialog-CSBsET-p.js";import{D as fe}from"./DataTable-mgItq60H.js";import{F as $}from"./Field-DEJ6Wjg9.js";import{C as E,I as be,a as je,b as ve,B as c,d as C,S as ye}from"./heroui-DZj66Arc.js";const Ne=50;function Se({value:t,onChange:a,users:r,label:l,description:i}){const[x,g]=o.useState(""),d=o.useMemo(()=>r.filter(s=>!s.user_id.startsWith("apikey-")).map(s=>({id:s.user_id,label:s.alias?`${s.user_id} (${s.alias})`:s.user_id})),[r]),p=o.useMemo(()=>{const s=x.trim().toLowerCase();return d.filter(u=>!t.includes(u.id)).filter(u=>!s||u.id.toLowerCase().includes(s)||u.label.toLowerCase().includes(s)).slice(0,Ne)},[d,t,x]),m=s=>{t.includes(s)||a([...t,s]),g("")},h=s=>a(t.filter(u=>u!==s));return e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsxs("div",{children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:l}),i?e.jsx("p",{className:"text-xs text-[var(--otari-muted)]",children:i}):null]}),t.length>0?e.jsx("div",{className:"flex flex-wrap gap-1.5",children:t.map(s=>e.jsxs("span",{className:"inline-flex items-center gap-1 rounded-full bg-[var(--otari-brand-tint)] px-2.5 py-1 font-mono text-xs text-[var(--otari-brand-dark)]",children:[s,e.jsx("button",{type:"button","aria-label":`Remove ${s}`,onClick:()=>h(s),className:"text-[var(--otari-brand-dark)] hover:text-red-700",children:"×"})]},s))}):null,d.length===0?e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users yet. Create users first, then assign them here or from the Users page."}):e.jsxs(E.Root,{allowsEmptyCollection:!0,menuTrigger:"input",inputValue:x,onInputChange:g,selectedKey:null,onSelectionChange:s=>{s!=null&&m(String(s))},className:"flex flex-col gap-1",children:[e.jsxs(E.InputGroup,{children:[e.jsx(be,{"aria-label":"Add a user",placeholder:"Search users…",autoComplete:"off"}),e.jsx(E.Trigger,{})]}),e.jsx(E.Popover,{children:e.jsx(je,{items:p,className:"max-h-72 overflow-auto",children:s=>e.jsx(ve,{id:s.id,textValue:s.label,children:s.label})})})]})]})}const _e=new Intl.NumberFormat(void 0,{style:"currency",currency:"USD",maximumFractionDigits:2});function P(t){return _e.format(t)}const j=86400,q=3600,z=[{label:"No reset",seconds:null},{label:"Daily",seconds:j},{label:"Weekly",seconds:7*j},{label:"Monthly",seconds:30*j}];function Ce(t){if(t===null)return"No reset";const a=z.find(r=>r.seconds===t);return a?a.label:t%j===0?`Every ${t/j} days`:t%q===0?`Every ${t/q} hours`:`Every ${t}s`}function W(t){if(!t)return"—";const a=new Date(t);return Number.isNaN(a.getTime())?"—":a.toLocaleString()}function we(t){const a=t.trim();if(a==="")return{value:null,valid:!0};const r=Number(a);return!Number.isFinite(r)||r<0?{value:null,valid:!1}:{value:r,valid:!0}}function Y(t){return t!==null&&t%j===0?String(t/j):""}function De({value:t,onChange:a,onInvalidChange:r}){const l=z.some(s=>s.seconds===t),[i,x]=o.useState(!l),[g,d]=o.useState(()=>Y(t)),p=g.trim(),m=Number(p),h=p!==""&&(!Number.isSafeInteger(m)||m<=0);return o.useEffect(()=>{r==null||r(h)},[h,r]),e.jsxs("div",{className:"flex flex-col gap-2",children:[e.jsx("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:"Reset period"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[z.map(s=>e.jsx(c,{size:"sm",variant:!i&&t===s.seconds?"primary":"outline",onPress:()=>{x(!1),d(Y(s.seconds)),a(s.seconds)},children:s.label},s.label)),e.jsx(c,{size:"sm",variant:i?"primary":"outline",onPress:()=>x(!0),children:"Custom"})]}),i?e.jsx("div",{className:"flex items-end gap-2",children:e.jsx($,{label:"Every N days",value:g,onChange:s=>{d(s);const u=Number(s.trim());a(s.trim()===""||!Number.isSafeInteger(u)||u<=0?null:u*j)},placeholder:"14",description:h?e.jsx("span",{className:"text-red-700",children:"Enter a whole number of days."}):"Whole days between resets."})}):null,e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"Spend returns to zero each period. A user’s clock starts when the budget is assigned to them."})]})}function G({title:t,submitLabel:a,initial:r,error:l,isPending:i,onSubmit:x,onClose:g,assignUsers:d}){const[p,m]=o.useState(r.name??""),[h,s]=o.useState(r.max_budget===null?"":String(r.max_budget)),[u,b]=o.useState(r.budget_duration_sec),[S,v]=o.useState(!1),[B,k]=o.useState([]),f=we(h),A=!i&&f.valid&&!S,w=()=>{A&&x({name:p.trim()||null,max_budget:f.value,budget_duration_sec:u},B)};return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-5",children:[e.jsx("div",{className:"text-sm font-semibold text-[var(--otari-ink)]",children:t}),e.jsx(O,{error:l}),e.jsx($,{label:"Name (optional)",value:p,onChange:m,autoFocus:!0,placeholder:"team-free-tier",description:"A label to recognize this budget later."}),e.jsx($,{label:"Spending limit (USD)",value:h,onChange:s,placeholder:"100.00",description:f.valid?"The most a single user on this budget may spend per period. Leave blank for no limit.":e.jsx("span",{className:"text-red-700",children:"Enter a non-negative number, or leave blank for no limit."})}),e.jsx(De,{value:u,onChange:b,onInvalidChange:v}),d?e.jsx(Se,{label:"Assign to users (optional)",description:"Attach this budget to existing users now. You can also manage assignments later on the Users page.",value:B,onChange:k,users:d}):null,e.jsxs("div",{className:"flex gap-2",children:[e.jsx(c,{variant:"primary",isDisabled:!A,onPress:w,children:i?"Saving…":a}),e.jsx(c,{variant:"ghost",isDisabled:i,onPress:g,children:"Cancel"})]})]})})}function Pe({budget:t}){if(t.user_count===0)return e.jsx("span",{className:"text-xs text-[var(--otari-muted)]",children:"No users assigned"});const a=t.total_spend;if(t.max_budget===null)return e.jsxs("span",{className:"text-xs text-[var(--otari-ink)]",children:[P(a)," spent",e.jsx("span",{className:"text-[var(--otari-muted)]",children:" · no limit"})]});const r=t.max_budget*t.user_count,l=r>0?Math.min(100,a/r*100):0,i=a>r;return e.jsxs("div",{className:"flex min-w-[140px] flex-col gap-1",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-2 text-xs",children:[e.jsx("span",{className:"text-[var(--otari-ink)]",children:P(a)}),e.jsxs("span",{className:"text-[var(--otari-muted)]",children:["of ",P(r)]})]}),e.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-[var(--otari-line)]",role:"progressbar","aria-valuenow":Math.round(l),"aria-valuemin":0,"aria-valuemax":100,"aria-label":"Aggregate spend against total allocation",children:e.jsx("div",{className:`h-full rounded-full ${i?"bg-red-500":"bg-[var(--otari-brand)]"}`,style:{width:`${Math.max(l,i?100:2)}%`}})})]})}function Be({budgetId:t}){const a=me(t);if(a.isLoading)return e.jsxs("div",{className:"flex items-center gap-2 px-4 py-4 text-sm text-[var(--otari-muted)]",children:[e.jsx(ye,{size:"sm"})," Loading reset history…"]});if(a.error)return e.jsx("div",{className:"px-4 py-4",children:e.jsx(O,{error:a.error})});const r=a.data??[];return r.length===0?e.jsx("div",{className:"px-4 py-4 text-sm text-[var(--otari-muted)]",children:"No resets recorded yet for this budget."}):e.jsx("div",{className:"overflow-x-auto px-4 py-3",children:e.jsxs("table",{className:"w-full border-collapse text-xs",children:[e.jsx("thead",{className:"text-left text-[var(--otari-muted)]",children:e.jsxs("tr",{children:[e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"User"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Spend cleared"}),e.jsx("th",{className:"py-1.5 pr-4 font-medium",children:"Reset at"}),e.jsx("th",{className:"py-1.5 font-medium",children:"Next reset"})]})}),e.jsx("tbody",{children:r.map(l=>e.jsxs("tr",{className:"border-t border-[var(--otari-line)]",children:[e.jsx("td",{className:"py-1.5 pr-4",children:e.jsx("code",{children:l.user_id??"—"})}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-ink)]",children:P(l.previous_spend)}),e.jsx("td",{className:"py-1.5 pr-4 text-[var(--otari-muted)]",children:W(l.reset_at)}),e.jsx("td",{className:"py-1.5 text-[var(--otari-muted)]",children:W(l.next_reset_at)})]},l.id))})]})})}function ke({onCreate:t}){return e.jsx(C,{children:e.jsxs(C.Content,{className:"flex flex-col gap-4 p-6",children:[e.jsxs("div",{children:[e.jsx("h2",{className:"text-lg font-semibold text-[var(--otari-ink)]",children:"No budgets yet"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--otari-muted)]",children:"A budget caps how much a user may spend and, optionally, resets that spend on a schedule. Create one, then assign it to users to enforce a limit."})]}),e.jsx("div",{children:e.jsx(c,{variant:"primary",onPress:t,children:"Create your first budget"})})]})})}function Ae({label:t,isPending:a,onConfirm:r}){const[l,i]=o.useState(!1);return l?e.jsxs("div",{className:"flex flex-col items-end gap-1.5 rounded-lg border border-amber-200 bg-amber-50 p-2 text-right",children:[e.jsxs("span",{className:"max-w-xs text-xs text-amber-800",children:["Delete ",e.jsx("strong",{children:t}),"? Users keep their spend but lose this limit. Cannot be undone."]}),e.jsxs("span",{className:"inline-flex gap-1",children:[e.jsx(c,{size:"sm",variant:"danger",isDisabled:a,onPress:r,children:"Delete permanently"}),e.jsx(c,{size:"sm",variant:"ghost",isDisabled:a,onPress:()=>i(!1),children:"Cancel"})]})]}):e.jsx(c,{size:"sm",variant:"danger-soft",onPress:()=>i(!0),children:"Delete"})}function Q(t){return t.split("-")[0]}function M(t){return t.name??Q(t.budget_id)}function Oe(){const t=re(),a=ae(),r=le(),l=ie(),i=oe(),x=de(),[g,d]=o.useState(!1),[p,m]=o.useState(null),[h,s]=o.useState(null),[u,b]=o.useState(null),[S,v]=o.useState(null),[B,k]=o.useState(!1),f=xe(),[A,w]=o.useState(!1),[X,T]=o.useState(void 0),[J,F]=o.useState(!1),D=t.data??[],H=t.isLoading,y=D.find(n=>n.budget_id===p)??null,U=D.find(n=>n.budget_id===h)??null,K=!H&&D.length===0&&!g,Z=D.map(n=>n.budget_id),_=ge(f.selectedKeys,Z),ee=async()=>{F(!0),T(void 0);try{for(const n of _)await i.mutateAsync(n);f.clear(),w(!1)}catch(n){T(n)}finally{F(!1)}},te=[{id:"budget",header:"Budget",isRowHeader:!0,cell:n=>e.jsxs("div",{className:"flex flex-col gap-0.5",children:[e.jsx("span",{className:"font-medium text-[var(--otari-ink)]",children:n.name??e.jsx("span",{className:"text-[var(--otari-muted)]",children:"(unnamed)"})}),e.jsx("code",{className:"text-[11px] text-[var(--otari-muted)]",title:n.budget_id,children:Q(n.budget_id)})]})},{id:"limit",header:"Limit (per user)",cell:n=>n.max_budget===null?e.jsx("span",{className:"text-[var(--otari-muted)]",children:"Unlimited"}):P(n.max_budget)},{id:"reset",header:"Reset",cell:n=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:Ce(n.budget_duration_sec)})},{id:"users",header:"Users",cell:n=>e.jsx("span",{className:"text-[var(--otari-muted)]",children:n.user_count})},{id:"usage",header:"Usage",cell:n=>e.jsx(Pe,{budget:n})},{id:"actions",header:"Actions",align:"end",cell:n=>e.jsxs("div",{className:"flex items-center justify-end gap-1.5",children:[e.jsx(c,{size:"sm",variant:"ghost",onPress:()=>s(N=>N===n.budget_id?null:n.budget_id),children:h===n.budget_id?"Hide history":"History"}),e.jsx(c,{size:"sm",variant:"ghost",onPress:()=>{d(!1),m(n.budget_id)},children:"Edit"}),e.jsx(Ae,{label:M(n),isPending:i.isPending,onConfirm:()=>i.mutate(n.budget_id)})]})}],V=async(n,N)=>{k(!0),b(null);const L=await Promise.allSettled(N.map(R=>x.mutateAsync({id:R,body:{budget_id:n}})));k(!1);const I=L.flatMap((R,ne)=>R.status==="rejected"?[N[ne]]:[]);if(I.length>0){v({budgetId:n,userIds:I}),b(new Error(`Budget created, but could not assign it to: ${I.join(", ")}. Retry to try again.`));return}v(null),d(!1)},se=(n,N)=>{if(S){V(S.budgetId,S.userIds);return}b(null),r.mutate(n,{onSuccess:async L=>{if(N.length>0){await V(L.budget_id,N);return}d(!1)}})};return e.jsxs("div",{className:"flex flex-col gap-6",children:[e.jsx(ue,{title:"Budgets",description:"Define spending limits and reset schedules. Assign a budget to users to enforce it.",action:g||K?null:e.jsx(c,{variant:"primary",onPress:()=>{m(null),b(null),v(null),d(!0)},children:"Create budget"})}),e.jsx(O,{error:t.error??r.error??l.error??i.error??x.error}),e.jsx(ce,{children:"Assign a budget to users when you create it, or later from the Users page. Each row’s usage aggregates the spend of the users currently on that budget."}),K?e.jsx(ke,{onCreate:()=>{m(null),b(null),v(null),d(!0)}}):null,g?e.jsx(G,{title:"Create budget",submitLabel:S?"Retry assignments":"Create budget",initial:{name:null,max_budget:null,budget_duration_sec:null},error:r.error??u,isPending:r.isPending||B,assignUsers:a.data??[],onSubmit:se,onClose:()=>{b(null),v(null),d(!1)}}):null,y?e.jsx(G,{title:`Edit budget ${M(y)}`,submitLabel:"Save changes",initial:{name:y.name,max_budget:y.max_budget,budget_duration_sec:y.budget_duration_sec},error:l.error,isPending:l.isPending,onSubmit:n=>l.mutate({id:y.budget_id,body:n},{onSuccess:()=>m(null)}),onClose:()=>m(null)},y.budget_id):null,_.length>0?e.jsx(he,{selectedCount:_.length,allMatching:!1,matchingTotal:null,canSelectAllMatching:!1,onSelectAllMatching:()=>{},onClear:f.clear,children:e.jsx(c,{size:"sm",variant:"danger",onPress:()=>w(!0),children:"Delete"})}):null,e.jsx(fe,{ariaLabel:"Budgets",columns:te,rows:D,getRowKey:n=>n.budget_id,isLoading:H,emptyContent:"No budgets yet. Create one to cap spending.",selectionMode:"multiple",selectedKeys:f.selectedKeys,onSelectionChange:f.onSelectionChange}),U?e.jsx(C,{children:e.jsxs(C.Content,{className:"p-0",children:[e.jsxs("div",{className:"flex items-center justify-between border-b border-[var(--otari-line)] px-4 py-2",children:[e.jsxs("span",{className:"text-sm font-medium text-[var(--otari-ink)]",children:["Reset history — ",M(U)]}),e.jsx(c,{size:"sm",variant:"ghost",onPress:()=>s(null),children:"Close"})]}),e.jsx(Be,{budgetId:U.budget_id})]})}):null,e.jsx(pe,{isOpen:A,onOpenChange:w,heading:"Delete budgets",body:`Delete ${_.length} ${_.length===1?"budget":"budgets"}? Users on ${_.length===1?"it":"them"} will no longer be capped.`,confirmLabel:"Delete",isPending:J,error:X,onConfirm:ee})]})}export{Oe as BudgetsPage};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve only failed targets after a partial bulk mutation.

Each loop aborts on its first rejected request and clears selection only after complete success. Completed rows therefore remain selected after a partial failure; retrying can re-delete already deleted rows or unnecessarily reapply prices. Track successful keys and retain only failed targets, or use an atomic batch API.

  • src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js#L1-L1: remove successfully deleted budget IDs from selection before surfacing failures.
  • src/gateway/static/dashboard/assets/AliasesPage-B5d3dytf.js#L1-L1: remove successfully deleted alias names from selection before surfacing failures.
  • src/gateway/static/dashboard/assets/ModelsPage-BS-xT7Ts.js#L1-L1: remove successfully repriced model keys from selection before surfacing failures.
📍 Affects 3 files
  • src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js#L1-L1 (this comment)
  • src/gateway/static/dashboard/assets/AliasesPage-B5d3dytf.js#L1-L1
  • src/gateway/static/dashboard/assets/ModelsPage-BS-xT7Ts.js#L1-L1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js` at line 1,
Update the bulk mutation handlers so partial failures retain only failed
targets, preventing retries from repeating successful operations: in
src/gateway/static/dashboard/assets/BudgetsPage-Dnhf8Nve.js:1-1, adjust Oe’s ee
delete flow to track rejected budget IDs and remove successful IDs from f
selection before surfacing errors; apply the same successful-target removal in
src/gateway/static/dashboard/assets/AliasesPage-B5d3dytf.js:1-1 for its bulk
alias deletion handler and in
src/gateway/static/dashboard/assets/ModelsPage-BS-xT7Ts.js:1-1 for its bulk
repricing handler. Clear the selection only when all targets succeed.

Comment on lines +421 to 444
// Custom range: an explicit from/to window (like the Activity page), buckets
// daily. When active it overrides the preset window.
const [customMode, setCustomMode] = useState(false);
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
const [modelFilter, setModelFilter] = useState("");
const [userFilter, setUserFilter] = useState("");
const [apiKeyFilter, setApiKeyFilter] = useState("");
const [metric, setMetric] = useState<ChartMetric>("cost");
// On mobile the user/model/key controls collapse behind a "Filters" toggle so
// the stat tiles are not pushed far down the page; desktop shows them inline.
// The time-range presets stay visible either way.
const [filtersOpen, setFiltersOpen] = useState(false);

const winStart = customMode ? customFrom || undefined : startDate;
const winEnd = customMode ? customTo || undefined : undefined;
const bucket: UsageBucket = customMode ? "day" : preset.bucket;

const filters: UsageFilters = useMemo(
() => ({
start_date: startDate,
start_date: winStart,
end_date: winEnd,
model: modelFilter.trim() || undefined,
user_id: userFilter || undefined,
api_key_id: apiKeyFilter || undefined,
}),
[startDate, modelFilter, userFilter, apiKeyFilter],
[winStart, winEnd, modelFilter, userFilter, apiKeyFilter],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -A20 'def _resolve_window' src/gateway/api/routes/usage.py

Repository: mozilla-ai/otari

Length of output: 1360


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'UsagePage\.tsx|usage\.py|apiFetch|usage' . | sed 's#^\./##' | head -200

echo "== usage route relevant window handling =="
sed -n '360,430p' src/gateway/api/routes/usage.py || true

echo "== TypeScript usage page relevant section =="
sed -n '380,460p' web/src/pages/UsagePage.tsx || true
sed -n '560,610p' web/src/pages/UsagePage.tsx || true

echo "== search date parsing in usage route =="
rg -n "start_date|end_date|parse|datetime|offset|UTC|naive" src/gateway/api/routes/usage.py web/src/pages/UsagePage.tsx

Repository: mozilla-ai/otari

Length of output: 17334


Make custom date pickers explicit, or convert them to UTC before filtering.

datetime-local values are wall-clock strings without timezone info, and the usage route treats offset-less datetimes as UTC. A user in a non-UTC timezone who picks 2026-03-01T13:00 to 14:00 will get a query window shifted by that offset. Since the chart labels say “times in UTC”, either label these inputs as UTC only, or convert customFrom/customTo to UTC ISO strings before using them as start_date/end_date.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/UsagePage.tsx` around lines 421 - 444, Update the custom date
range handling in UsagePage around customFrom, customTo, winStart, and winEnd so
datetime-local selections are explicitly labeled as UTC or converted to UTC ISO
strings before populating UsageFilters.start_date and end_date; preserve the
existing preset behavior and ensure the chart query window matches the displayed
UTC semantics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants