Add quota UI: inline gates, usage awareness, and notifications - #2528
Conversation
|
[Improvement] If
Overall the quota single-source module + widened 402 body look solid. Found 1 issue (0 critical, 1 improvement). |
dd30b76 to
4923d17
Compare
There was a problem hiding this comment.
[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 => |
There was a problem hiding this comment.
[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
unreadOnlycase, clear the list and reset paging (e.g.setItems([]),setPagesLoaded(0),setHasMore(false)), or refetch page 0 and reset paging state.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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}.", |
There was a problem hiding this comment.
[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)?
There was a problem hiding this comment.
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 …”).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thanks — that addresses my concern. Aligning the 402 message with the org-subject copy (and keeping the existing test assertion) sounds good.
|
Rebased on the latest commits — the two earlier points still look outstanding: [Improvement]
[Improvement] Backend 402 body:
|
|
Rechecked latest commits — earlier feedback still applies: [Improvement]
[Improvement] Backend 402 body:
Found 2 issues (0 critical, 2 improvements). |
|
Rechecked the latest commits: both previously-raised items look addressed.
Looks good. Ship it. |
There was a problem hiding this comment.
[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) { |
There was a problem hiding this comment.
[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”).
There was a problem hiding this comment.
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.
|
Rechecked the latest commits (preview-pipeline quota gate + stream timeout + QuotaNotice update). [Improvement] Quota 402s from Backend now correctly blocks the preview pipeline with
Everything else in these commits (quota gate on preview, NDJSON idle timeout + tests, quota-aware error events in the backend stream) looks solid. |
|
Rechecked the latest commits (explorer suggestion-stream failure surfacing + preview-pipeline quota gate + NDJSON stall timeout) — overall still looks solid. [Improvement]
|
|
Rechecked after the latest commits: Given that (and the server-side |
5966614 to
0a1f8a4
Compare
|
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>
8435b9e to
0b31414
Compare
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
kindandperiod_end, so the client can classify a blocked resource and name its reset date without a second round trip.period_endisnullfor stock resources, which never reset.usage_notificationsservice 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 intoincrement_usage; stock resources hook into their three creation routes.QUOTA_RESOURCE_LABELSmirrors the frontend label map, so the backend stops callingtest_executions"Test Executions" where the rest of the product says "Test Runs".Frontend
utils/quota.tsis 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.limittoceilingand shows the overage allowance as a hatched second segment, so a soft-tier org can see the band it still has.GET /notifications/docstring already called for. Quota alerts land in a newusagesection that badges no nav item. Mark-all-read now clears the list (rather than just stampingread_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.useQuotaGateanduseQuotaErrorHandler.useQuotaGatetakes 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:readis granted to every member) and "Upgrade plan" (gated oncanUpgrade) links rendered by the component instead of folded into the recourse string.useCreateEndpoint,useDeleteEndpoint,useCreateProject,useDeleteProject,useCreateUser,useDeleteUser, that invalidates the cached/usageresponse 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 outsidesrc/hooks/, so a future call site can't reintroduce the same bug silently.test_generationlimit could open the stream, and if the underlying model call stalled (as it does whenmodel_tokensis also exhausted), the UI hung on skeleton loaders forever with no notification. Fixed at three points: the frontend now preflight-checkstest_generationbefore every preview-generation entry point, not just the final submit; the backend route now carries the samerequire_quota(TEST_GENERATION)dependency/test_sets/generatealready has, and the one un-guarded LLM-resolution call in the pipeline now catches a quota exhaustion (including the existingmodel_tokenspre-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-timeoutExplorerClientalready has, so any stall surfaces as a clear error rather than an infinite hang.suggestion_pipeline_stream(live, wired toSuggestionsDialog) andevaluate_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-itemerrorfieldoutput/evaluationevents already carry for one row failing without stopping the rest. The quota-aware message-building this needs is a sharedquota.enforcement.stream_error_message, not a third copy of the sameisinstance(e, QuotaExceededError)check.Review Fixes
A review pass over this branch's own commits turned up several gaps, all fixed:
createEndpoint(a server action) never throws on a business failure -- it returns{success: false, error}-- and bothEndpointFormandSwaggerEndpointFormignored that, always showing the success toast and navigating away. An org at its endpoints limit saw a false success with nothing actually created.generateTestPipelineStreamthrew a plainErrorwith the unparsed response body, sorequire_quota(added earlier in this PR) produced a toast reading the literal{"error":"quota_exceeded",...}payload. Now throws the samestatus/data-bearing shapeBaseApiClientitself uses, and all five preview-generation catch blocks (two,handleRegenerateSample/handleLoadMoreSamples, had neither a preflight check nor real error handling) tryasQuotaError()first, same as the final submit already did._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 sametry./generate/tests,/generate/multiturn-testsand/generate/test_configback the same preview flow as the now-gated streaming route but had norequire_quotadependency of their own.useCreateProject/useDeleteProjectinvalidated usage but not the project list cache, unlike their siblinguseDeleteEndpoint-- a project picker elsewhere in the app kept a deleted project listed untilstaleTimeelapsed.overflow: hiddenits sibling (the also-badged org-icon button) never had.overflowX: 'visible'removed fromFilterDrawerShell(the CSS Overflow spec promotes a lonevisibleaxis toauto, 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
mainafter Self-hosted deployments get no usage quotas #2524, which madeUSAGE_QUOTAS_ENABLEDdefault to off for self-hosted. With quotas off every limit isnull, 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.capabilities.tsclaimedusage:readwas "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, andOrganization.UPDATEis the gate for "can act on billing", which is what the upgrade CTA now uses. The comment is fixed too.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_stampwas fillingproject_idon org-wide notifications from the acting admin's active project, so RLS then hid them in every other project. AndfindWorstResourcesorted 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.usage_attribution.pyexists on the backend and the usage page is its eventual home.Testing
Automated:
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), andEndpointForm's create-success/create-failure paths (2).test_quota_gates.py,test_quota_enforcement.py,test_test_generation_pipeline.py,test_services.py,test_suggestions.py), including a regression test for theproject_idstamping that fails without its fix, and new coverage for the test-generation and Explorer suggestion pipelines' 402 gates and LLM-resolution error paths (including aQuotaExceededErrorsurfacing 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 --noEmitclean;npm run lintreports 0 errors (61 pre-existing warnings, none from this PR's own files);ruff check/formatclean on every touched Python file.Manual, on a community-edition org:
USAGE_QUOTAS_ENABLEDoff, confirm the badge, banner, gates and org-menu block are all absent.test_generationat 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.