Skip to content

Add quota UI: inline gates, usage awareness, and notifications - #2528

Merged
harry-rhesis merged 37 commits into
mainfrom
harry-rhesis/quota-system-ui
Aug 24, 2026
Merged

Add quota UI: inline gates, usage awareness, and notifications#2528
harry-rhesis merged 37 commits into
mainfrom
harry-rhesis/quota-system-ui

Conversation

@harry-rhesis

@harry-rhesis harry-rhesis commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Purpose

Backend quota enforcement landed in #2505, but the UI barely reflected it: a banner at 80% and a preflight gate in one drawer, both phrased as though the reader personally owned a limit their organization owns. This makes quota legible everywhere it matters and fixes the framing.

One rule drives all of it: quota is organization state. Personal chrome never carries it, the org is always the grammatical subject ("Your organization is at its projects limit"), and "you" appears only for what the reader can actually do next.

What Changed

Backend

  • The 402 body carries kind and period_end, so the client can classify a blocked resource and name its reset date without a second round trip. period_end is null for stock resources, which never reset.
  • New usage_notifications service notifies the org owner when usage crosses 80% of a limit or its ceiling. It compares the before/after range rather than classifying the new value, so an org sitting past a threshold is not renotified on every subsequent call. Flow resources hook into increment_usage; stock resources hook into their three creation routes.
  • QUOTA_RESOURCE_LABELS mirrors the frontend label map, so the backend stops calling test_executions "Test Executions" where the rest of the product says "Test Runs".

Frontend

  • utils/quota.ts is the single source for classification and copy: classifyZone (healthy / approaching / pastIncluded / blocked), quotaCopy (the copy catalog as code), parseQuotaError, flaggedResources. Every surface reads from it instead of re-deriving thresholds or hand-writing strings.
  • The usage page moves red from limit to ceiling and shows the overage allowance as a hatched second segment, so a soft-tier org can see the band it still has.
  • A count badge on the brand row and an "Org usage" block in the org menu give ambient awareness. The badge answers "how many"; the banner and bars answer "how bad". Each org-menu usage row now fills a bar to how much of its limit is used, and the user-menu avatar badges its own unread-notification count.
  • A notifications drawer, the drill-down the GET /notifications/ docstring already called for. Quota alerts land in a new usage section that badges no nav item. Mark-all-read now clears the list (rather than just stamping read_at) when filtered to "Unread only" -- otherwise rows the user just marked read stayed on screen contradicting their own filter -- and same-day rows now have visible spacing between them instead of sitting flush against each other.
  • Inline gates on projects, endpoints, seats and test generation via two hooks, useQuotaGate and useQuotaErrorHandler. useQuotaGate takes an amount, because inviting five people consumes five seats and a per-1 gate waves through a submit the backend refuses partway. The shared <QuotaNotice> these gates render now matches the design record's Q4 mockup: a zone-coloured icon and left border, not just a bold sentence, plus separate "Org usage" (unconditional -- usage:read is granted to every member) and "Upgrade plan" (gated on canUpgrade) links rendered by the component instead of folded into the recourse string.
  • Every stock-resource mutation (endpoints, projects, users/seats) now goes through a dedicated hook, useCreateEndpoint, useDeleteEndpoint, useCreateProject, useDeleteProject, useCreateUser, useDeleteUser, that invalidates the cached /usage response on success. Five call sites (both endpoint-creation forms, the duplicate-endpoint action, and onboarding's user and project creation) had bypassed the hook and called the API client or a server action directly, so a user who did what a quota notice told them to do could still see themselves as blocked for up to five minutes. An ESLint rule now blocks calling those methods outside src/hooks/, so a future call site can't reintroduce the same bug silently.
  • The AI test-generation preview ("Generate Test Set"'s sample/config stream) had no quota awareness anywhere: the frontend only gated the final submit, the backend enforced nothing on the streaming endpoint, and the NDJSON reader had no timeout. An org already over its test_generation limit could open the stream, and if the underlying model call stalled (as it does when model_tokens is also exhausted), the UI hung on skeleton loaders forever with no notification. Fixed at three points: the frontend now preflight-checks test_generation before every preview-generation entry point, not just the final submit; the backend route now carries the same require_quota(TEST_GENERATION) dependency /test_sets/generate already has, and the one un-guarded LLM-resolution call in the pipeline now catches a quota exhaustion (including the existing model_tokens pre-call gate) and emits a proper error event instead of letting it escape a generator whose response had already committed; and the NDJSON stream reader gets the same 150s idle-timeout ExplorerClient already has, so any stall surfaces as a clear error rather than an infinite hang.
  • The same "unguarded LLM resolution before the first yield" defect existed independently in Explorer's suggestion_pipeline_stream (live, wired to SuggestionsDialog) and evaluate_suggestions_stream (no frontend caller yet). Both now catch the failure and emit a new top-level {"type": "error", "message": ...} event -- a pipeline-level failure, distinct from the per-item error field output/evaluation events already carry for one row failing without stopping the rest. The quota-aware message-building this needs is a shared quota.enforcement.stream_error_message, not a third copy of the same isinstance(e, QuotaExceededError) check.

Review Fixes

A review pass over this branch's own commits turned up several gaps, all fixed:

  • Endpoint creation showed "created successfully" on a 402. createEndpoint (a server action) never throws on a business failure -- it returns {success: false, error} -- and both EndpointForm and SwaggerEndpointForm ignored that, always showing the success toast and navigating away. An org at its endpoints limit saw a false success with nothing actually created.
  • The test-generation preview's 402 showed raw JSON. generateTestPipelineStream threw a plain Error with the unparsed response body, so require_quota (added earlier in this PR) produced a toast reading the literal {"error":"quota_exceeded",...} payload. Now throws the same status/data-bearing shape BaseApiClient itself uses, and all five preview-generation catch blocks (two, handleRegenerateSample/handleLoadMoreSamples, had neither a preflight check nor real error handling) try asQuotaError() first, same as the final submit already did.
  • The same "unguarded setup before the first yield" defect also lived in _fetch_db_context, one line after the LLM-resolution call this PR had already wrapped -- a deleted/inaccessible project reintroduced the exact hang this whole fix exists to prevent. Folded into the same try.
  • Three more generation routes had no quota gate at all. /generate/tests, /generate/multiturn-tests and /generate/test_config back the same preview flow as the now-gated streaming route but had no require_quota dependency of their own.
  • useCreateProject/useDeleteProject invalidated usage but not the project list cache, unlike their sibling useDeleteEndpoint -- a project picker elsewhere in the app kept a deleted project listed until staleTime elapsed.
  • The sidebar avatar's unread badge was clipped by an overflow: hidden its sibling (the also-badged org-icon button) never had.
  • Smaller: a no-op overflowX: 'visible' removed from FilterDrawerShell (the CSS Overflow spec promotes a lone visible axis to auto, so it never did anything); the GREYSCALE/EE-boundary ESLint selectors, copied into three rule blocks, hoisted into shared constants (180→269 lines, no behavior change); two duplicate 8-line "why this try/except exists" comments in the two streaming pipelines consolidated into one docstring both point at.

Additional Context

  • Builds on feat: enforce usage quotas across execute, generate, and hosted-model paths #2505. Rebased onto main after Self-hosted deployments get no usage quotas #2524, which made USAGE_QUOTAS_ENABLED default to off for self-hosted. With quotas off every limit is null, so the badge, banner, gates and notifications all correctly disappear; the last commit fixes a header that was still rendering over zero rows in that case.
  • A stale comment sent the original design down the wrong path. capabilities.ts claimed usage:read was "restricted to org admins". feat: enforce usage quotas across execute, generate, and hosted-model paths #2505 had already removed that restriction, deliberately, on the reasoning that quota enforcement blocks members so hiding the reason from them just turns a 402 into a support ticket. Several surfaces were built admin-only on that false premise and have been corrected: everyone sees the numbers, and Organization.UPDATE is the gate for "can act on billing", which is what the upgrade CTA now uses. The comment is fixed too.
  • Three defects worth calling out, all found reviewing this work rather than in production. An unguarded block after increment_usage's commit meant a raised exception made Celery retry an accrual that had already landed, inflating the counter and blocking the org at a fraction of its real limit. auto_stamp was filling project_id on org-wide notifications from the acting admin's active project, so RLS then hid them in every other project. And findWorstResource sorted by ratio alone, which let a soft-tier resource deep in its grace band outrank a hard-blocked one and hide a real block behind a warning.
  • Deferred: per-user attribution ("who used the last slot"). usage_attribution.py exists on the backend and the usage page is its eventual home.

Testing

Automated:

  • Frontend: 215 suites, 2036 tests. New coverage for utils/quota.ts (43, including every zone boundary, every copy-catalog row, and the usage-bar fill-percent helper), the quota hooks (12), <QuotaNotice> (4), the notifications drawer (18, including mark-all-read-while-filtered and same-day row spacing), the sidebar's usage bar and unread badge, ServicesClient.generateTestPipelineStream's NDJSON parsing, stall timeout and 402 handling (3), and EndpointForm's create-success/create-failure paths (2).
  • Backend: the quota, usage and notification suites pass (87 tests across test_quota_gates.py, test_quota_enforcement.py, test_test_generation_pipeline.py, test_services.py, test_suggestions.py), including a regression test for the project_id stamping that fails without its fix, and new coverage for the test-generation and Explorer suggestion pipelines' 402 gates and LLM-resolution error paths (including a QuotaExceededError surfacing the organization-subject copy, not the exception's technical string, in each). Caveat: the full backend suite has not been re-run since the rebase onto Self-hosted deployments get no usage quotas #2524 — Docker on this machine wedged under disk pressure. Since Self-hosted deployments get no usage quotas #2524 touches the same quota module, please let CI confirm, or re-run the suites named above locally.
  • npx tsc --noEmit clean; npm run lint reports 0 errors (61 pre-existing warnings, none from this PR's own files); ruff check/format clean on every touched Python file.

Manual, on a community-edition org:

  1. Set a small limit and consume most of it. The brand-row badge appears with a count, the org menu shows "Org usage" with per-resource rows, and the banner names the organization rather than you.
  2. Cross the ceiling. Submit buttons disable with an explanation in the drawer footer, never on the FAB that opens it. The owner gets a notification, visible from any project.
  3. Delete the blocking project, then via the Swagger import form and the manual form, create an endpoint. Both the project-deletion and endpoint-creation counts update immediately rather than after the cache expires.
  4. Open the notifications drawer's filter panel. The "Unread only" switch renders flush against the left edge, not clipped.
  5. As a non-admin member, confirm the numbers are visible but no upgrade link is offered.
  6. With USAGE_QUOTAS_ENABLED off, confirm the badge, banner, gates and org-menu block are all absent.
  7. With test_generation at its limit, open "Generate Test Set" and try to continue past the input screen, regenerate samples, or use "Further refine". Each shows the blocking toast immediately rather than opening the stream.
  8. Trigger a 402 on a gated drawer as a member and as an admin. Both show the icon, the bordered notice, and an "Org usage" link; only the admin also sees "Upgrade plan".

@peqy

peqy Bot commented Aug 20, 2026

Copy link
Copy Markdown

[Improvement] NotificationsDrawer “Unread only” doesn’t stay true after marking rows read

If unreadOnly is on, both handleRowClick and handleMarkAllRead only set read_at locally, so read rows remain visible even though the filter says “Unread only”.

Fix: when unreadOnly is true, remove the row(s) from items (e.g. filter them out) or trigger a refetch (fetchPage(0, true) + reset paging state) after marking read, so the UI matches the filter semantics.

Overall the quota single-source module + widened 402 body look solid.

Found 1 issue (0 critical, 1 improvement).

@harry-rhesis
harry-rhesis force-pushed the harry-rhesis/quota-system-ui branch from dd30b76 to 4923d17 Compare August 20, 2026 09:39

@peqy peqy 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.

[Improvement] NotificationsDrawer “Unread only” doesn’t match behavior after “Mark all read”: rows stay visible (just tinted as read) even though the filter implies the list should become empty.

[Question] Backend 402 quota body message still uses “You've …”; given the MR’s org-subject copy rule, should that become “Your organization …” as a safety net for any surface that shows raw API error messages?

(Object.keys(unreadBySection) as NotificationSection[]).forEach(section => {
if ((unreadBySection[section] ?? 0) > 0) markSectionRead(section);
});
setItems(prev =>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Improvement] When unreadOnly is enabled, handleMarkAllRead marks read_at locally but keeps the rows in items, so the UI still shows notifications even though the filter says “Unread only”.

Fix: in the unreadOnly case, clear the list and reset paging (e.g. setItems([]), setPagesLoaded(0), setHasMore(false)), or refetch page 0 and reset paging state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rechecked on the latest commits: if (unreadOnly) setHasMore(false) helps pagination, but the list still renders rows that were just marked read. If unreadOnly is true, I think handleMarkAllRead should also clear/filter items (and ideally reset pagesLoaded) so the UI immediately flips to the “You’re all caught up” empty state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 658dfbc: handleMarkAllRead now clears items and resets pagesLoaded/hasMore when unreadOnly is true, instead of leaving now-read rows rendered. Added a regression test (empties the list immediately when marking all read while filtered to unread).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks — that fix matches what I was aiming for (clear the list + reset paging under unreadOnly), and the regression test is a nice guard. 👍

"limit": verdict.limit,
"kind": verdict.kind,
"period_end": verdict.period_end,
"message": f"You've reached your {resource_display} limit{suffix}.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Question] quota_exceeded_response_body() still returns a message with “You've reached your …”. Since this MR’s copy rule is “quota is organization state”, should this be “Your organization …” as well (in case any client surfaces data.message directly)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Follow-up: the frontend can surface data.message from 402s (base-client falls back to errorData.message, and parseQuotaError also carries it), so it’s worth aligning this string with the “quota is organization state” copy too (e.g. “Your organization is at its … limit …”).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 23b4525: reworded to "Your organization is at its {resource} limit...", matching quotaCopy()'s phrasing on the frontend. The one test asserting on this string checks for "test runs" in body["message"], which still holds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks — that addresses my concern. Aligning the 402 message with the org-subject copy (and keeping the existing test assertion) sounds good.

@peqy

peqy Bot commented Aug 20, 2026

Copy link
Copy Markdown

Rebased on the latest commits — the two earlier points still look outstanding:

[Improvement] NotificationsDrawer: when “Unread only” is enabled, handleMarkAllRead marks read_at locally but keeps rows in items, so the list still shows notifications despite the filter.

Fix: in the unreadOnly case, clear/filter items (and reset paging state) or refetch page 0 after marking read.

[Improvement] Backend 402 body: quota_exceeded_response_body() still sets message to “You've reached your …”. The frontend can surface data.message, so this should probably follow the MR’s “quota is organization state” rule.

Fix: change to “Your organization …” (and keep the period suffix logic).

@peqy

peqy Bot commented Aug 20, 2026

Copy link
Copy Markdown

Rechecked latest commits — earlier feedback still applies:

[Improvement] NotificationsDrawer: with “Unread only” enabled, handleMarkAllRead marks read_at locally but keeps rows in items, so the list still shows notifications despite the filter.

Fix: in the unreadOnly case, clear/filter items (and reset paging state) or refetch page 0.

[Improvement] Backend 402 body: quota_exceeded_response_body() still returns message as “You've reached your …”. The frontend can surface data.message, so this should follow the “quota is organization state” copy rule.

Fix: change to “Your organization …” (keeping the period suffix logic).

Found 2 issues (0 critical, 2 improvements).

@peqy

peqy Bot commented Aug 20, 2026

Copy link
Copy Markdown

Rechecked the latest commits: both previously-raised items look addressed.

  • NotificationsDrawer: under “Unread only”, handleMarkAllRead now clears items / resets paging so the list immediately goes empty.
  • Backend 402 body: quota_exceeded_response_body() now uses org-subject copy and includes kind/period_end.

Looks good. Ship it.

@peqy peqy 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.

[Question] Template auto-init + quota gating may still fail open while usage is loading (see inline comment).

Otherwise, the latest follow-ups (unread-only mark-all-read + org-subject 402 message + preview gating) look solid.

Found 1 issue (0 critical, 0 improvements, 1 question).

// silently-swallowed failure mid-stream left the skeleton loaders
// stuck forever with no toast. Blocking here, before the stream ever
// opens, is the same check handleGenerate already makes.
if (generationQuota.message) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Question] Potential race with usage loading

useQuotaGate() returns message: undefined while usage is still loading (fails open). In the selectedTemplateId auto-init path, that means a quota-blocked org could still start generateTestPipelineStream() before usage arrives, and then never hit this guard.

Fix (if this is possible in practice): consider waiting for usage to be loaded before auto-starting the template stream (or add an explicit “usage loading” signal to the gate and treat that as “don’t auto-run yet”).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rechecked after the latest commits: /services/generate/test_pipeline is now quota-gated server-side (require_quota(TEST_GENERATION)), and ServicesClient.generateTestPipelineStream() now throws an error with status + parsed data, so asQuotaError(...)?.message surfaces the org-subject quota copy cleanly.

Given that, the “usage still loading → fails open” window in useQuotaGate() shouldn’t cause a silent bypass anymore (worst case you get a handled 402 + toast), so I’m comfortable with this now.

@peqy

peqy Bot commented Aug 20, 2026

Copy link
Copy Markdown

Rechecked the latest commits (preview-pipeline quota gate + stream timeout + QuotaNotice update).

[Improvement] Quota 402s from /services/generate/test_pipeline won’t surface the quota copy cleanly

Backend now correctly blocks the preview pipeline with require_quota(TEST_GENERATION), but ServicesClient.generateTestPipelineStream() still throws a plain Error with a stringified body on non-2xx. That means the frontend can’t parse quota errors via parseQuotaError/useQuotaErrorHandler, so users may see raw JSON / generic “Failed to load template” rather than the org-subject quota message.

Fix: on !response.ok, parse the JSON body and throw an error that preserves status + data in the same shape as BaseApiClient.fetch (and set message from data.message), then use asQuotaError(e)?.message ?? … in the preview/template path too.

Everything else in these commits (quota gate on preview, NDJSON idle timeout + tests, quota-aware error events in the backend stream) looks solid.

@peqy

peqy Bot commented Aug 21, 2026

Copy link
Copy Markdown

Rechecked the latest commits (explorer suggestion-stream failure surfacing + preview-pipeline quota gate + NDJSON stall timeout) — overall still looks solid.

[Improvement] ServicesClient.generateTestPipelineStream() still throws a plain Error on !response.ok (stringified body). That means 402 quota responses from /services/generate/test_pipeline can’t be parsed by parseQuotaError / useQuotaErrorHandler, so the UI may show raw JSON / generic errors instead of the org-subject quota copy.

Fix: when !response.ok, parse the JSON body (when present) and throw an Error that preserves status + data in the same shape as BaseApiClient.fetch (and set message from data.message). Then callers can do asQuotaError(e)?.message ?? getApiErrorMessage(e, ...) and quota UX stays consistent.

@peqy

peqy Bot commented Aug 21, 2026

Copy link
Copy Markdown

Rechecked after the latest commits: ServicesClient.generateTestPipelineStream() now preserves status + parsed data on !response.ok, so 402 quota responses from /services/generate/test_pipeline can be parsed by parseQuotaError / useQuotaErrorHandler and show the org-subject quota copy.

Given that (and the server-side require_quota(TEST_GENERATION) gate now in place), the remaining quota/streaming surfaces look solid. Ship it.

@harry-rhesis
harry-rhesis force-pushed the harry-rhesis/quota-system-ui branch from 5966614 to 0a1f8a4 Compare August 21, 2026 14:33
@peqy

peqy Bot commented Aug 21, 2026

Copy link
Copy Markdown

Rechecked the latest update: the new commit is just a CI retrigger (no code diff). The quota-gated streaming + error surfacing fixes still look solid. Ship it.

The frontend needs to classify a blocked resource as flow/stock and show
its reset date without a second round trip to GET /usage.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Detects the transition -- not just the zone -- when a resource crosses 80%
of its limit or its ceiling, so an org already past a threshold isn't
renotified on every subsequent accrual or creation. Flow resources hook
into increment_usage's upsert; stock resources (seats, projects,
endpoints) hook into their single creation route, right after
require_quota lets the request through.

Notifies organization.owner_id only, not every Organization.UPDATE
holder -- the full set needs a reverse RBAC lookup that doesn't exist yet.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
classifyZone/quotaCopy/parseQuotaError turn a UsageResourceItem or a 402
body into a zone (healthy/approaching/pastIncluded/blocked) and its
sentence + recourse -- the copy catalog as code, so every surface (banner,
badge, org menu, usage page, inline gates) reads from one place instead of
re-deriving thresholds or hand-writing strings.

ApiErrorData grows kind/period_end to match the widened 402 body.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
QuotaBanner speaks org-first ("Your organization...") across all three
non-healthy zones, counts stock resources instead of percentaging them,
and only offers "Upgrade" to someone who can act on it (Organization.UPDATE
on a community-edition org) -- usage:read itself is granted to every org
member, so it can no longer stand in for "is this an admin".

UsageOverviewTab moves red from limit to ceiling, adds a hatched overage-
allowance segment past the included amount, and a caption naming the
allowance (or saying there isn't one).

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Adds a count badge to the brand row (collapsed and expanded) for
resources at or near their limit, and a matching "Org usage" block in the
org menu -- period, plan chip, one row per flagged resource padded to a
minimum of three, an upgrade link gated on Organization.UPDATE. Visible to
every member, not just admins: usage:read is granted org-wide.

Adds a "Notifications" entry to the user menu opening a new
NotificationsDrawer: paginated history across every section, day-grouped,
with an unread-only toggle and mark-all-read. Backs the "usage" section
this branch's quota notifications write into, which badges no nav item
but still needs somewhere to display.

COUNT_BADGE_SX in theme-constants.ts shares the badge's look across all
three places it now appears, instead of a copy per call site.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
BaseDrawer's error slot now takes a ReactNode, not just a string, so a
quota gate isn't forced into error-red when nothing's actually blocked yet.
RunDrawer's preflight gate and its reactive 402 catch both build a
QuotaNotice now instead of a hardcoded "you've reached your limit" string,
and gate the upgrade recourse on Organization.UPDATE rather than
usage:read.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Three defects found reviewing the threshold-crossing work.

The org lookup and policy resolution ran after increment_usage's commit but
outside any guard. accrue_usage catches bare Exception and retries, so a
raise there re-accrued a counter that had already landed: four retries, four
increments, and the org gets blocked at a quarter of its real limit. The
guard now covers the whole post-commit block, and the stock-resource count
moved inside it too -- routers/project.py commits the project before that
runs, so a failure there returned 500 for a project that exists.

_notify_owner passed project_id=None, but auto_stamp fills a null project_id
from the session scope and the three creation routes are project-scoped. The
row landed stamped with whichever project the admin had selected, and the
RESTRICTIVE project_isolation policy then hid it everywhere else. Pinned
with temporary_project_scope; the regression test fails without it.

Notification copy said "Test Executions" where the rest of the product says
"Test Runs". Adds QUOTA_RESOURCE_LABELS to the quota module, mirroring the
frontend map, and routes the 402 message through it.

Also: period_end is None for stock resources rather than a meaningless
calendar date, and drops a guard that could never be false.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
findWorstResource sorted by ratio alone. Ratio is measured against limit, so
a soft-tier resource at 150/100 (still running) outranked a hard-blocked one
at 1/1 -- and since the banner shows only the worst resource, a real block
was invisible. Sorts by zone severity first now.

quotaCopy's legacy-402 branch had its recourse inverted, so an admin, the
only person who can act, got an empty string.

Clicking a notification row called the client directly and never told
NotificationsContext, so the bell kept counting it. Other sections
self-healed by accident via the pathname effect; usage has no nav route, so
it never did. Adds markOneRead, which decrements by item_count.

Also in the drawer: offset pagination duplicated and skipped rows, a failed
fetch rendered as "No notifications yet", and relativeTime rounded so 90
minutes read as "2h ago".

Dead code: countFlagged had no callers, an unreachable limit-0 branch was
documented as load-bearing, and two zone checks could never be true (fixed
properly by narrowing FlaggedResource.zone to exclude healthy). Deduped
isNotificationSection from three copies, pointed NavItem at COUNT_BADGE_SX
(the "shared" constant had shipped already forked), and collapsed six copies
of the menu-row styling.

The Sidebar was repeating the Object.values(...)[0] period bug that
UsageOverviewTab carries a comment warning about; it picks a flow resource
deliberately now, and its row padding moved into a pure usageMenuRows.

capabilities.ts claimed usage is "restricted to org admins". PR #2505 removed
that restriction; the stale comment is what led the design astray.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Rolls the inline gates out past RunDrawer to projects, endpoints, seats and
test generation, and fixes the cache bug that made the blocked-state recourse
a lie.

Two hooks carry it so each of the twelve sites is a couple of lines rather
than forty, and so the threshold and the copy cannot drift apart:

- useQuotaGate(resource, amount) does the preflight. It compares against
  ceiling rather than limit, fails open while usage loads, and takes an
  amount because inviting five people consumes five seats -- a per-1 gate
  waves through a submit the backend refuses partway. useQuotaMessageFor is
  the string form for a cost only known at submit time.
- useQuotaErrorHandler turns a caught 402 into the same copy, backing up
  every preflight for the window between render and submit.

RunDrawer is refactored onto them; its tests pass unchanged, which is the
evidence this is behaviour-preserving.

useInvalidateUsage drops the cached /usage response after a stock mutation,
wired at all eight create/delete sites for projects, endpoints and seats.
Without it a user who deleted a project because a notice told them to stayed
blocked for up to five minutes, having done exactly what was asked. Flow
resources stay on staleTime deliberately: a stale flow count only drifts up,
so the gate errs open and the 402 catches it.

Gates go on the submit, never on the control that opens the drawer -- a
disabled FAB leaves the explanation nowhere to live. Surfaces with no error
slot (invites, test generation) get sentence and recourse in a toast, with no
link, since a snackbar auto-dismisses.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
#2524 made USAGE_QUOTAS_ENABLED default to off for self-hosted, so /usage
reports every resource with a null limit. That yields no rows, and the block
was gated on the resource map being non-empty rather than on having rows, so
a period-and-plan header stood alone over nothing.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
styles/theme-constants.ts is where the hardcoded-styles CI check finds every
literal color/spacing value everywhere else in the app -- it's the token
*definition* site, so editing it drags its own pre-existing literals (the
GREYSCALE hexes, BACKDROP_COLORS, ELEVATION) into the diff the checker scans.

COUNT_BADGE_SX is nav chrome, not a design token, so it belongs in
sidebar-utils.ts beside collapsedNavItemSx. theme-constants.ts and theme.ts
now match main exactly.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
"Community plan" doesn't fit the org-menu usage block's width; "Community"
reads fine on its own since the chip already sits beside a plan-shaped
context.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
"Community"/"Enterprise" read as inconsistent with the rest of the app's
lowercase-first labels; "community"/"enterprise" matches.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Three things reported against the running build.

Reorders "Switch project" and the divider ahead of the usage block, not
after it: with a block to show, the everyday navigation items (Projects,
Switch project) now stay together at the top instead of splitting across a
wall of usage rows. Abbreviates the header month ("Aug 2026") to match.

The divider between the two groups used `greyscale.border`, a token tuned
for the app's regular surface1/2 backgrounds -- against this popover's own
near-black/near-white paper it sat close enough in luminance to read as no
divider at all, in both themes. Replaced with an alpha overlay computed
against black or white by mode, which is visible against either paper
color by construction rather than by coincidence.

Widens the org-menu popover from 188px to 252px: it carries the period
label and the plan chip on one row, and 188px (sized for "Dark Mode"/"Sign
Out" text) left them touching.

The usage block's header and rows are now their own click target to
/organizations/usage, matching the "Org usage" row above them -- a reader
no longer has to aim for that one row when the whole block goes to the
same page. "Upgrade plan" stays a sibling outside that button rather than
nested in it, so a click can't fire both destinations at once.

Also moves NavItem's badge onto the shared COUNT_BADGE_SX it was already
supposed to use (missed in the earlier extraction).

Signed-off-by: Harry Cruz <harry@rhesis.ai>
…inks

Closes the gaps found comparing NotificationsDrawer against the design
artifact: rows now color by severity (quota-blocked/failure red, quota
warning amber, success green) with distinct icons per case, each row
links to its destination ("View test run", "Org usage", etc.), a batch
row shows a "N items" pill instead of inline "(N)", and the empty state
copy matches the mockup.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Sidebar org-menu usage rows now fill a bar to how much of the resource's
limit is used, matching the design record's mockup instead of showing
only the trailing count. The user-menu avatar also gets a badge for
unread notifications, so unread state is visible without opening the
menu.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
FilterDrawerShell's scroll container only set overflow-y, so the
browser silently promoted overflow-x to auto too and clipped the
"Unread only" switch, whose FormControlLabel carries a -11px default
left margin flush against the box's left edge. Setting overflow-x to
visible explicitly stops that promotion.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Endpoint deletion (and several other stock-resource create/delete
flows) called the API client or a server action directly, bypassing
the invalidateUsage() call the equivalent mutation hook already made
elsewhere. That left the quota UI showing a stale count until a full
page refresh.

Consolidates every stock-resource mutation (endpoints, projects,
users/seats) into a dedicated hook that invalidates usage once,
centrally: useCreateEndpoint, useDeleteEndpoint, useCreateProject,
useDeleteProject, useCreateUser, useDeleteUser. An ESLint rule now
blocks calling these API client methods (or importing the
createEndpoint server action) from anywhere outside src/hooks/, so a
future call site can't reintroduce the same bug silently.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
handleMarkAllRead set read_at on every loaded row but left them in the
list, so a user filtering to "Unread only" still saw rows they had
just marked read. Every row loaded under that filter came from the
server with unread_only: true, so marking them all read means the
correct list is empty, not a list of rows with read_at now set.

Addresses a peqy review comment on this PR.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
quota_exceeded_response_body()'s message still said "You've reached
your ... limit", personal framing this PR's own rule ("quota is
organization state") replaced everywhere else. Reworded to match the
"Your organization is at its ... limit" phrasing quotaCopy() already
uses on the frontend.

Addresses a peqy review comment on this PR.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Each day's rows rendered in a plain Box with no gap between them, so
same-day notifications sat flush against each other with no visual
separation. The gap between day groups was already set; this adds the
same spacing within a group.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
The row-spacing fix used gap: '4px' instead of the numeric MUI
spacing multiplier (gap: 0.5) every other Box in this codebase uses,
reintroducing the hardcoded-style pattern the project explicitly
avoids.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
handleGenerate (the final "Continue to Confirmation" submit) already
blocked when test_generation quota was exhausted, but the three
earlier calls that stream the config/sample preview -- template init,
"continue" from the input screen, regenerate samples, and the chat
refine box -- had no such check. The streaming pipeline itself has no
quota enforcement of its own, so an already-exhausted org sailed
straight into it and, if the underlying call hung instead of
throwing, was left staring at skeleton loaders forever with no error
shown anywhere.

Moved the existing useQuotaGate/useQuotaErrorHandler calls earlier in
the component and added the same preflight check handleGenerate uses
to all four preview-generation entry points.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
QuotaNotice rendered only a bold sentence and a plain caption line --
no icon, no bordered box, no clickable recourse links. The design
record's Q4 mockup (patterns A/B, "Inline gates: org subject,
recourse by kind") specifies a bordered notice with a zone-colored
icon and left border, plus separate "Org usage" and "Upgrade plan"
links rendered by the component itself rather than folded into the
recourse string.

"Org usage" is unconditional (usage:read is granted to every member),
"Upgrade plan" stays gated on canUpgrade, matching QuotaBanner and the
org menu's own upgrade row. Added a test file, since none existed.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
readNdjsonStream had no timeout on reader.read(), unlike its sibling
ExplorerClient, which already guards the same NDJSON pattern with a
150s idle timeout. A stall anywhere in the pipeline (an exhausted
model-tokens quota that made the backend's LLM call hang, a network
blip, anything) left reader.read() awaiting forever with no event
ever firing, so the UI's loading state never cleared and no error
ever showed.

Mirrors ExplorerClient's readChunkWithTimeout exactly. Also adds a
ReadableStream polyfill to jest.setup.js (jsdom has none), needed to
test streaming Response bodies at all.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
POST /generate/test_pipeline (the streaming sample preview shown before
a test set is created) had no quota enforcement of its own: no
require_quota dependency, no enforce_quota call, nothing. An org
already at its TEST_GENERATION limit could open the stream and start
generating anyway.

Adds the same require_quota(QuotaResource.TEST_GENERATION) dependency
already used on /test_sets/generate and /owasp/generate, so a real 402
fires before the stream ever opens.

Separately, resolving the config LLM was the one step in the whole
generator with no surrounding try/except -- a resolution failure
(including the pre-call MODEL_TOKENS gate in user_model_utils.py)
propagated straight out of an async generator whose 200 OK had already
been sent, aborting the stream with no event ever reaching the
frontend. Both LLM-resolution call sites now catch the failure and
yield a proper error event instead, using the same organization-subject
copy every other quota surface uses when it's a QuotaExceededError.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Both suggestion_pipeline_stream and evaluate_suggestions_stream had the
same shape of bug just fixed in the test-generation pipeline: the first
statement in each generator resolves a model (or evaluation metrics,
which resolve one) with no surrounding try/except, before the first
yield. A failure there -- including the MODEL_TOKENS pre-call gate in
user_model_utils.py raising QuotaExceededError -- propagated straight
out of the generator after the StreamingResponse had already committed
its 200 OK, so the stream just closed with no event ever reaching the
frontend.

Both now catch the failure and yield a new top-level {"type": "error",
"message": ...} event before finishing the stream normally. Extracted
the quota-aware message-building this needs (QuotaExceededError gets
the organization-subject copy every other surface uses, anything else
falls back to its own message) into a shared
quota.enforcement.stream_error_message, replacing the copy that was
about to be duplicated a third time in test_generation_pipeline.py.

evaluate_suggestions_stream has no frontend caller yet, but carries the
same defect and the fix costs nothing extra to include now.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
The backend's suggestion_pipeline_stream now emits a top-level
{"type": "error", "message": ...} event when it fails before producing
any suggestion (e.g. an exhausted quota during model resolution).
Added the matching PipelineErrorEvent type and a case in
SuggestionsDialog's event switch that sets the same error state the
dialog's outer catch already uses, instead of silently dropping the
event as an unhandled union member.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
_fetch_db_context (e.g. a deleted/inaccessible project raises there)
sat right after _resolve_config_llm but outside its try/except,
reintroducing the exact bug that block exists to prevent: a failure
after StreamingResponse's 200 OK has already committed, with no event
ever reaching the frontend. Folded it into the same try.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Both test_generation_pipeline.py and suggestions.py carried an
8-line comment explaining the same trap (StreamingResponse commits
its 200 OK before the first yield, so a setup failure has no clean
402 left to send). Moved the full explanation into
stream_error_message's docstring, the one place already shared by
both call sites, and left a one-line pointer at each.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
/generate/tests, /generate/multiturn-tests and /generate/test_config
back the same sample-preview flow as /generate/test_pipeline
(already gated) but had no require_quota dependency of their own --
an org already at its TEST_GENERATION limit could still call them
directly, only stopped once the MODEL_TOKENS gate happened to fire
deeper in model resolution. Same require_quota(TEST_GENERATION)
dependency the streaming route and /test_sets/generate already use.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
createEndpoint (a server action) never throws on a business failure --
a 402 from an exhausted endpoints quota included -- it returns
{success: false, error} instead, per its own doc comment. Both
EndpointForm and SwaggerEndpointForm ignored that and always showed
"Endpoint created successfully!" and navigated away regardless, so a
user at their limit saw a false success with no endpoint actually
created. Both now check result.success and surface result.error
through the same getApiErrorMessage() path other forms use.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
generateTestPipelineStream threw a plain Error with the raw JSON
response body on a non-OK response, so the require_quota gate just
added to that route showed a toast reading the literal
'{"error":"quota_exceeded",...}' payload instead of the quota
sentence -- neither getApiErrorMessage() (needs the "API error: "
prefix) nor parseQuotaError() (needs .status/.data) could read it.
Now throws the same shape BaseApiClient's own fetch() does, via a
newly-exported parseApiErrorResponse().

All five preview-generation catch blocks (including
handleRegenerateSample and handleLoadMoreSamples, which had neither a
preflight check nor error-message handling at all) now try
asQuotaError() first, same as the final submit already did, so a
reactive 402 gets the proper organization-subject copy instead of a
generic API error string.

Also fixes a test-isolation gap: jest.useRealTimers() ran only on the
stalled-stream test's happy path, so a failed assertion there would
have leaked fake timers into every later test in the file. Moved it
into afterEach.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
useDeleteEndpoint invalidates endpointKeys.all() on success, but its
siblings useCreateProject/useDeleteProject only invalidated usage, not
projectKeys -- a page reading useProjects (e.g. a project picker) kept
a deleted project listed, or missed a newly created one, until
staleTime elapsed. This whole hook file exists so no caller has to
remember cache invalidation per mutation; these two were the exception.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
The user-avatar button set overflow: hidden, clipping the unread-count
Badge MUI positions half outside its anchor by design -- especially
collapsed, where the button has only ~4px of slack around the 32px
avatar. The org-icon button right above it (also badged) never had
this overflow rule; removed it here too rather than inventing a new
pattern.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
Per CSS Overflow 3, a lone axis set to visible computes to auto when
the other axis isn't visible/clip -- so overflowX: 'visible' next to
overflowY: 'auto' had no effect, identical to leaving it unset. The
comment stated the opposite of what the spec actually does. The
FormControlLabel ml: 0 fix (already in this codebase) is what actually
stops the "Unread only" switch from clipping.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
The GREYSCALE no-restricted-syntax selectors were copied into three
rule blocks (the base ruleset, the src/hooks/ override, and the
src/actions/ override) and the EE-boundary no-restricted-imports
patterns into two, growing the file from 180 to 300 lines with no
behavior difference between copies. Hoisted both into module-level
constants and referenced them everywhere; verified the guards still
fire identically (same 61 pre-existing warnings, 0 errors).

Signed-off-by: Harry Cruz <harry@rhesis.ai>
@harry-rhesis
harry-rhesis force-pushed the harry-rhesis/quota-system-ui branch from 8435b9e to 0b31414 Compare August 21, 2026 14:48
@harry-rhesis
harry-rhesis merged commit 11029d2 into main Aug 24, 2026
25 checks passed
@harry-rhesis
harry-rhesis deleted the harry-rhesis/quota-system-ui branch August 24, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant