From 0ab5c946c3d99bf7f3fc1ca7bab88a46bd170a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:35:29 +0000 Subject: [PATCH 1/4] feat(app-shell,i18n): render the environment admin's read-rate report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tenant runtime's `GET /api/v1/usage/storage` gained an optional nested `readRate` reading (state, readsPerWrite, ratioThreshold). The data half landed on the cloud side and nothing here consumed it, so a measured anomaly reached nobody. This is the rendering half. `useReadRateReading` reads the endpoint and keeps the three silent answers apart: an absent `readRate` is `unmeasured` (the control plane reported no reading), a measured `ok` is `measured`, and an unreadable endpoint is `unavailable`. All three render nothing; none of them is the others. `ReadRateBanner` renders only on the control plane's `anomalous` verdict, which it reads and never re-derives. An absent `readsPerWrite` means the environment made no writes at all, so the ratio is unbounded — it gets its own title, its own sentence and the heavier tone rather than a dash or a hidden banner. The line comes from `ratioThreshold` on the wire; this repo holds no copy of it. It is a report: no gate, no throttle, no CTA, and the copy says so. Mounted in `ConsoleShell` beside `ImpersonationBanner` so `/home` carries it too, and shown only to a workspace admin, who is also the only session that issues the request. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .changeset/9954-read-rate-banner.md | 37 +++ .../app-shell/src/console/ConsoleShell.tsx | 10 + .../__tests__/useReadRateReading.test.tsx | 190 ++++++++++++++ packages/app-shell/src/hooks/index.ts | 11 + .../app-shell/src/hooks/useReadRateReading.ts | 243 ++++++++++++++++++ .../app-shell/src/layout/ReadRateBanner.tsx | 129 ++++++++++ .../layout/__tests__/ReadRateBanner.test.tsx | 163 ++++++++++++ packages/app-shell/src/layout/index.ts | 1 + packages/i18n/src/locales/ar.ts | 7 + packages/i18n/src/locales/de.ts | 7 + packages/i18n/src/locales/en.ts | 12 + packages/i18n/src/locales/es.ts | 7 + packages/i18n/src/locales/fr.ts | 7 + packages/i18n/src/locales/ja.ts | 7 + packages/i18n/src/locales/ko.ts | 7 + packages/i18n/src/locales/pt.ts | 7 + packages/i18n/src/locales/ru.ts | 7 + packages/i18n/src/locales/zh.ts | 7 + 18 files changed, 859 insertions(+) create mode 100644 .changeset/9954-read-rate-banner.md create mode 100644 packages/app-shell/src/hooks/__tests__/useReadRateReading.test.tsx create mode 100644 packages/app-shell/src/hooks/useReadRateReading.ts create mode 100644 packages/app-shell/src/layout/ReadRateBanner.tsx create mode 100644 packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx diff --git a/.changeset/9954-read-rate-banner.md b/.changeset/9954-read-rate-banner.md new file mode 100644 index 0000000000..2a02156235 --- /dev/null +++ b/.changeset/9954-read-rate-banner.md @@ -0,0 +1,37 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': minor +--- + +Render the environment admin's read-rate report from the usage endpoint's `readRate` +reading (objectui#9954; maintainer ruling on cloud#2333, batch #164 item 3). + +The tenant runtime's `GET /api/v1/usage/storage` gained one optional nested key, +`readRate`, carrying the control plane's verdict (`state`), the ratio it measured +(`readsPerWrite`) and the line that verdict was taken against (`ratioThreshold`). The +data half landed on the cloud side; nothing in this repo consumed it, so a measured +anomaly reached nobody. This is the rendering half. + +**New:** `useReadRateReading` (a hook beside `useAiUsage`) and `ReadRateBanner` (a +layout surface beside `ImpersonationBanner`, mounted in `ConsoleShell` so every console +route including `/home` carries it). Both are exported from `@object-ui/app-shell`. + +Three properties of the contract shape the implementation, and each is pinned by a test: + +- **An absent `readRate` is not "fine".** It means the control plane reported NO + reading. The hook reports it as `unmeasured`, which is a different value from a + measured `ok` and from an unreadable endpoint. All three render nothing, and the code + keeps all three apart — "why does my environment show no banner" has more than one + answer and one of them is *nobody has measured it*. +- **An absent `readsPerWrite` is the worst case, not a missing number.** It means the + environment made no writes at all, so the ratio has no upper bound. It gets its own + title, its own sentence and the heavier tone — never a dash, and never a hidden + banner. +- **The threshold is data.** It is rendered from `ratioThreshold` on the wire; this repo + holds no copy of the line, and the verdict is never re-derived from the ratio. + +It is a **report**: no gate, no throttle, no upgrade call to action, and the copy says in +as many words that nothing is limited or blocked. It is shown only to a workspace admin, +who is also the only session that issues the request. + +`@object-ui/i18n` gains the four `console.readRate.*` keys in all ten locale packs. diff --git a/packages/app-shell/src/console/ConsoleShell.tsx b/packages/app-shell/src/console/ConsoleShell.tsx index dee3dd1921..655fea42c9 100644 --- a/packages/app-shell/src/console/ConsoleShell.tsx +++ b/packages/app-shell/src/console/ConsoleShell.tsx @@ -41,6 +41,7 @@ import { RedirectWithSplash } from '../chrome/RedirectWithSplash.js'; import { RemediationOverlay } from './RemediationOverlay.js'; import { HostNavigationBridge } from './HostNavigationBridge.js'; import { ImpersonationBanner } from '../layout/ImpersonationBanner.js'; +import { ReadRateBanner } from '../layout/ReadRateBanner.js'; // The console's every pre-React / pre-auth gate (Suspense fallback, adapter // not ready, org/auth loading) renders this. It used to be a bare, unbranded @@ -173,6 +174,15 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) { header it warns about. Renders null on every ordinary session. */} + {/* objectui#9954 — the environment admin's read-rate report. + Beside the impersonation indicator for the same reason it + is here: chrome for EVERY console page, including `/home`, + which has its own layout and would otherwise carry no + report. Renders null unless the control plane's verdict is + `anomalous`, so an ordinary session and an unmeasured + environment both see nothing — and a non-admin session + never even issues the request. */} + }>{children} {/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */} diff --git a/packages/app-shell/src/hooks/__tests__/useReadRateReading.test.tsx b/packages/app-shell/src/hooks/__tests__/useReadRateReading.test.tsx new file mode 100644 index 0000000000..705d4d08f2 --- /dev/null +++ b/packages/app-shell/src/hooks/__tests__/useReadRateReading.test.tsx @@ -0,0 +1,190 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * useReadRateReading (objectui#9954) — reads the tenant runtime's `readRate` + * off `GET /api/v1/usage/storage` and keeps the three "no banner" answers apart. + * + * The behaviours pinned here are contract properties of the reading, not + * rendering choices: + * - an ABSENT `readRate` is `'unmeasured'`, never the same value as a + * measured `'ok'` — the card's property (1); + * - an ABSENT `readsPerWrite` survives parsing as an absent key, because its + * absence IS the no-writes case — the card's property (2); + * - the verdict is read, not re-derived — a reading whose `readsPerWrite` sits + * far above `ratioThreshold` but whose `state` says `'ok'` stays `'ok'` — + * the card's property (3). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { + useReadRateReading, + classifyReadRate, + resolveRuntimeApiBase, + type ReadRateSnapshot, +} from '../useReadRateReading'; + +// `createAuthenticatedFetch` reads `response.headers` to adopt a rotated +// session token, so a stub without them is not a Response this lane can use. +function okResponse(body: unknown) { + return { + ok: true, + status: 200, + headers: new Headers(), + json: async () => body, + } as unknown as Response; +} + +/** Render the hook against one payload and settle on a terminal status. */ +async function readPayload(payload: unknown) { + const fetchMock = vi.fn().mockResolvedValue(okResponse(payload)); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useReadRateReading({ apiBase: '/api/v1' })); + await waitFor(() => expect(result.current.status).not.toBe('loading')); + return { result, fetchMock }; +} + +describe('useReadRateReading', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches {apiBase}/usage/storage and exposes the reading', async () => { + const { result, fetchMock } = await readPayload({ + readRate: { state: 'anomalous', readsPerWrite: 4210.5, ratioThreshold: 500 }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/usage/storage', + expect.objectContaining({ method: 'GET' }), + ); + expect(result.current.status).toBe('measured'); + expect(result.current.reading).toEqual({ + state: 'anomalous', + readsPerWrite: 4210.5, + ratioThreshold: 500, + }); + }); + + // Property (1). An absent `readRate` means the control plane reported NO + // reading. If this ever equals the measured-and-under-the-line answer, the two + // different answers to "why does my environment show no banner" have been + // collapsed into one. + it('reports an ABSENT readRate as `unmeasured`, which is NOT the measured `ok` answer', async () => { + const absent = await readPayload({ storage: { bytes: 1 } }); + expect(absent.result.current.status).toBe('unmeasured'); + expect(absent.result.current.reading).toBeNull(); + + vi.unstubAllGlobals(); + const measuredOk = await readPayload({ + readRate: { state: 'ok', readsPerWrite: 3, ratioThreshold: 500 }, + }); + expect(measuredOk.result.current.status).toBe('measured'); + expect(measuredOk.result.current.reading?.state).toBe('ok'); + + expect(absent.result.current.status).not.toBe(measuredOk.result.current.status); + expect(classifyReadRate(absent.result.current)).toBe('unmeasured'); + expect(classifyReadRate(measuredOk.result.current)).toBe('ok'); + }); + + // Property (2). The key must survive parsing as ABSENT — not defaulted, not + // coerced to a number — because its absence is the no-writes reading. + it('keeps an ABSENT readsPerWrite absent, in both spellings, and classifies it as the no-writes case', async () => { + const omitted = await readPayload({ + readRate: { state: 'anomalous', ratioThreshold: 500 }, + }); + expect(omitted.result.current.reading).toEqual({ state: 'anomalous', ratioThreshold: 500 }); + expect(omitted.result.current.reading).not.toHaveProperty('readsPerWrite'); + expect(classifyReadRate(omitted.result.current)).toBe('anomalous-no-writes'); + + vi.unstubAllGlobals(); + // A serializer that spells an omitted optional as JSON `null` must reach the + // same case — reading it as off-contract would HIDE the worst reading. + const nulled = await readPayload({ + readRate: { state: 'anomalous', readsPerWrite: null, ratioThreshold: 500 }, + }); + expect(classifyReadRate(nulled.result.current)).toBe('anomalous-no-writes'); + }); + + // Property (3). `state` is the control plane's verdict. A ratio far above the + // threshold with `state: 'ok'` must stay `ok`: re-deriving the verdict here + // would flip it. + it('reads `state` as the verdict and never re-derives it from the ratio', async () => { + const { result } = await readPayload({ + readRate: { state: 'ok', readsPerWrite: 99_999, ratioThreshold: 500 }, + }); + expect(result.current.reading?.state).toBe('ok'); + expect(classifyReadRate(result.current)).toBe('ok'); + }); + + it('fails soft to `unavailable` on a non-2xx, and does NOT claim `unmeasured`', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: false, status: 403, headers: new Headers() } as unknown as Response); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useReadRateReading({ apiBase: '/api/v1' })); + await waitFor(() => expect(result.current.status).toBe('unavailable')); + expect(result.current.reading).toBeNull(); + expect(classifyReadRate(result.current)).toBe('unavailable'); + }); + + it('refuses an off-contract readRate rather than coercing it', async () => { + const badState = await readPayload({ + readRate: { state: 'degraded', readsPerWrite: 3, ratioThreshold: 500 }, + }); + expect(badState.result.current.status).toBe('unavailable'); + + vi.unstubAllGlobals(); + const noThreshold = await readPayload({ readRate: { state: 'anomalous' } }); + expect(noThreshold.result.current.status).toBe('unavailable'); + + vi.unstubAllGlobals(); + const badRatio = await readPayload({ + readRate: { state: 'anomalous', readsPerWrite: 'lots', ratioThreshold: 500 }, + }); + expect(badRatio.result.current.status).toBe('unavailable'); + }); + + it('is inert when disabled — no request at all', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useReadRateReading({ apiBase: '/api/v1', enabled: false })); + await act(async () => { + await Promise.resolve(); + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.status).toBe('idle'); + expect(classifyReadRate(result.current)).toBe('pending'); + }); + + it('resolveRuntimeApiBase trims a trailing slash off an explicit base', () => { + expect(resolveRuntimeApiBase('/api/v1/')).toBe('/api/v1'); + expect(resolveRuntimeApiBase('/api/v1')).toBe('/api/v1'); + }); + + it('classifyReadRate gives every distinct fact its own value', () => { + const cases: Array<[ReadRateSnapshot, string]> = [ + [{ status: 'idle', reading: null }, 'pending'], + [{ status: 'loading', reading: null }, 'pending'], + [{ status: 'unavailable', reading: null }, 'unavailable'], + [{ status: 'unmeasured', reading: null }, 'unmeasured'], + [{ status: 'measured', reading: { state: 'ok', readsPerWrite: 2, ratioThreshold: 500 } }, 'ok'], + [ + { status: 'measured', reading: { state: 'anomalous', readsPerWrite: 900, ratioThreshold: 500 } }, + 'anomalous-ratio', + ], + [ + { status: 'measured', reading: { state: 'anomalous', ratioThreshold: 500 } }, + 'anomalous-no-writes', + ], + ]; + for (const [snapshot, expected] of cases) { + expect(classifyReadRate(snapshot)).toBe(expected); + } + // The three "renders nothing" answers are three values, never one. + expect(new Set(['unavailable', 'unmeasured', 'ok']).size).toBe(3); + }); +}); diff --git a/packages/app-shell/src/hooks/index.ts b/packages/app-shell/src/hooks/index.ts index d68e29bcd5..3118a51ec1 100644 --- a/packages/app-shell/src/hooks/index.ts +++ b/packages/app-shell/src/hooks/index.ts @@ -30,6 +30,17 @@ export { type AiUsageResetKind, type AiUsagePlanType, } from './useAiUsage.js'; +export { + useReadRateReading, + classifyReadRate, + resolveRuntimeApiBase, + type UseReadRateReadingOptions, + type UseReadRateReadingReturn, + type ReadRateBannerReading, + type ReadRateReadingStatus, + type ReadRateSnapshot, + type ReadRateBannerCase, +} from './useReadRateReading.js'; export { useRecentItems, type RecentItem } from './useRecentItems.js'; export { useRecordApprovals, type ApprovalRequestLite } from './useRecordApprovals.js'; export { useResponsiveSidebar } from './useResponsiveSidebar.js'; diff --git a/packages/app-shell/src/hooks/useReadRateReading.ts b/packages/app-shell/src/hooks/useReadRateReading.ts new file mode 100644 index 0000000000..ea3959be23 --- /dev/null +++ b/packages/app-shell/src/hooks/useReadRateReading.ts @@ -0,0 +1,243 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * useReadRateReading — the console's data hook for the tenant runtime's + * read-rate reading (objectui#9954, cloud#2333). + * + * Reads `GET {apiBase}/usage/storage` and exposes the ONE key of that response + * this repo carries a contract for: the optional nested `readRate`. The rest of + * the storage-usage payload is deliberately NOT modelled here — no contract for + * it reached this side, and inventing one would be a second, unowned copy of a + * shape the control plane owns. + * + * ## Why the three "no banner" answers stay three values + * + * A banner renders only on `state: 'anomalous'`, so silence, `'ok'` and an + * unreachable endpoint all render nothing. They are NOT the same fact, and + * collapsing them in the code is the defect this hook exists to prevent: + * + * - `'unmeasured'` — the endpoint answered and carried NO `readRate`. The + * control plane reported no reading. It does NOT mean "measured and under + * the line"; "why does my environment show no banner" has two answers and + * this is the one that says *nobody has measured it*. + * - `'measured'` + `state: 'ok'` — measured, and under the line. + * - `'unavailable'` — nothing trustworthy came back at all (network, non-2xx, + * or a `readRate` that does not match the contract). Deliberately NOT folded + * into `'unmeasured'`: that status is a positive claim about what the + * control plane said, and a failed read gives no basis to make it. + * + * ## Contract-first parsing (AGENTS.md #0.1) + * + * An off-contract `readRate` — an unknown `state`, a missing/!numeric + * `ratioThreshold` — is reported as `'unavailable'`, never coerced into a + * reading. The one shape that IS accepted in two spellings is an ABSENT + * `readsPerWrite`: `undefined` and JSON `null` are the two ways a serializer + * spells an omitted optional, and that value's absence is load-bearing — it is + * the environment having made no writes at all, the worst reading there is. A + * strict reading of `null` as off-contract would HIDE that case, which is + * exactly the failure the card forbids. Any OTHER non-numeric value is + * off-contract and is refused. + * + * ## No refetch loop + * + * Unlike the AI usage meter beside it, this reading is a slow control-plane + * aggregate over the environment's whole read history — nothing a user does in + * the console moves it within a session. So the hook fetches once per mount / + * apiBase change and exposes `refetch` for a caller that wants another look; + * there is no visibility or event-driven refresh to spend requests on. + * + * Fail-soft throughout: every failure path lands on a status the caller can + * read, never a throw. + * + * @module + */ +import * as React from 'react'; +import { createAuthenticatedFetch } from '@object-ui/auth'; + +/** + * The tenant runtime's read-rate reading, as `GET /api/v1/usage/storage` + * declares it. Mirrors the cloud runtime's `ReadRateBannerReading`. + */ +export interface ReadRateBannerReading { + /** `'anomalous'` = over the line (render the banner); `'ok'` = measured and under it. */ + state: 'ok' | 'anomalous'; + /** + * `rowsRead / rowsWritten`. ABSENT means the environment made no writes at + * all — an unbounded ratio, and the most severe reading there is. It is never + * "a number we failed to get". + */ + readsPerWrite?: number; + /** + * The line `state` was taken against. Carried as DATA on purpose so this side + * holds no second copy of a number that moves. + */ + ratioThreshold: number; +} + +/** What the hook knows right now. See the module header for why these are distinct. */ +export type ReadRateReadingStatus = + /** Inert — the caller gated the hook off (e.g. the viewer is not an admin). */ + | 'idle' + /** A request is in flight and nothing is known yet. */ + | 'loading' + /** No trustworthy reading could be obtained (network, non-2xx, off-contract payload). */ + | 'unavailable' + /** The endpoint answered and carried NO reading — the control plane measured nothing. */ + | 'unmeasured' + /** The endpoint answered with a reading that matches the contract. */ + | 'measured'; + +/** A reading is present exactly when the status is `'measured'`. */ +export type ReadRateSnapshot = + | { status: Exclude; reading: null } + | { status: 'measured'; reading: ReadRateBannerReading }; + +/** + * The rendering case a snapshot resolves to. One value per distinct fact, so a + * consumer cannot accidentally treat "nobody measured" as "measured and fine". + */ +export type ReadRateBannerCase = + /** Nothing known yet (inert or in flight). */ + | 'pending' + /** No trustworthy reading could be obtained. */ + | 'unavailable' + /** The control plane reported no reading. */ + | 'unmeasured' + /** Measured, and under the line. */ + | 'ok' + /** Over the line, with NO writes at all — unbounded, the most severe reading. */ + | 'anomalous-no-writes' + /** Over the line, with a finite reads-per-write ratio. */ + | 'anomalous-ratio'; + +/** + * Resolve a snapshot to its rendering case. + * + * `state` is the control plane's verdict and is READ, never re-derived: nothing + * here compares `readsPerWrite` against `ratioThreshold`. The only thing the + * anomalous branch decides is WHICH WORDS the reading deserves, which is a + * rendering question the verdict does not answer. + */ +export function classifyReadRate(snapshot: ReadRateSnapshot): ReadRateBannerCase { + if (snapshot.status !== 'measured') { + if (snapshot.status === 'unavailable') return 'unavailable'; + if (snapshot.status === 'unmeasured') return 'unmeasured'; + return 'pending'; // 'idle' | 'loading' + } + if (snapshot.reading.state === 'ok') return 'ok'; + return snapshot.reading.readsPerWrite === undefined ? 'anomalous-no-writes' : 'anomalous-ratio'; +} + +/** + * Resolve the tenant runtime API base — `${VITE_SERVER_URL}/api/v1`, the same + * origin + prefix the console's other `/api/v1/*` callers use. + */ +export function resolveRuntimeApiBase(explicit?: string): string { + if (explicit) return explicit.replace(/\/$/, ''); + // Typed narrowly rather than through `any` — the only member read is the one + // named here (AGENTS.md #6). + const env = (import.meta as ImportMeta & { env?: Record }).env ?? {}; + const serverUrl = env.VITE_SERVER_URL ?? ''; + return `${serverUrl.replace(/\/$/, '')}/api/v1`; +} + +export interface UseReadRateReadingOptions { + /** Override the resolved tenant runtime base (e.g. `/api/v1`). */ + apiBase?: string; + /** Gate the whole hook. Default true; `false` keeps it inert and issues no request. */ + enabled?: boolean; +} + +/** The snapshot itself, so `reading` narrows with `status`, plus an explicit refetch. */ +export type UseReadRateReadingReturn = ReadRateSnapshot & { refetch: () => void }; + +const UNMEASURED = Symbol('no readRate on the response'); + +/** + * Parse the endpoint's payload into a reading. + * + * Returns {@link UNMEASURED} when the response carried no `readRate`, `null` + * when what it carried does not match the contract, and the reading otherwise. + */ +function parseReading(payload: unknown): ReadRateBannerReading | typeof UNMEASURED | null { + if (!payload || typeof payload !== 'object') return null; + const raw = (payload as { readRate?: unknown }).readRate; + if (raw === undefined || raw === null) return UNMEASURED; + if (typeof raw !== 'object') return null; + + const { state, readsPerWrite, ratioThreshold } = raw as { + state?: unknown; + readsPerWrite?: unknown; + ratioThreshold?: unknown; + }; + if (state !== 'ok' && state !== 'anomalous') return null; + if (typeof ratioThreshold !== 'number' || !Number.isFinite(ratioThreshold)) return null; + + // Absent (either spelling) = no writes at all. See the module header. + if (readsPerWrite === undefined || readsPerWrite === null) return { state, ratioThreshold }; + if (typeof readsPerWrite !== 'number' || !Number.isFinite(readsPerWrite) || readsPerWrite < 0) { + return null; + } + return { state, readsPerWrite, ratioThreshold }; +} + +/** + * Load this environment's read-rate reading. See the module header for the + * status model, the parsing contract and the refetch policy. + */ +export function useReadRateReading(options: UseReadRateReadingOptions = {}): UseReadRateReadingReturn { + const { apiBase, enabled = true } = options; + const [snapshot, setSnapshot] = React.useState({ status: 'idle', reading: null }); + const [reloadToken, setReloadToken] = React.useState(0); + + const refetch = React.useCallback(() => setReloadToken((n) => n + 1), []); + + const base = React.useMemo(() => resolveRuntimeApiBase(apiBase), [apiBase]); + + React.useEffect(() => { + if (!enabled) return; // inert — the idle snapshot is DERIVED below, not written + if (typeof fetch !== 'function') return; // non-browser env → stay inert (fail-soft) + let cancelled = false; + // Built INSIDE the effect on purpose: a memoised identity may never be an + // effect dependency (AGENTS.md #10), and this is the console's `/api/v1/*` + // lane — the Bearer token lives in localStorage, there is no session cookie, + // so a bare `fetch` here would be unauthenticated. + const authFetch = createAuthenticatedFetch(); + setSnapshot({ status: 'loading', reading: null }); + authFetch(`${base}/usage/storage`, { + method: 'GET', + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + .then(async (res) => { + if (!res.ok) throw new Error(`Failed to load storage usage (${res.status})`); + return res.json(); + }) + .then((payload) => { + if (cancelled) return; + const parsed = parseReading(payload); + if (parsed === UNMEASURED) setSnapshot({ status: 'unmeasured', reading: null }); + else if (parsed === null) setSnapshot({ status: 'unavailable', reading: null }); + else setSnapshot({ status: 'measured', reading: parsed }); + }) + .catch(() => { + if (cancelled) return; + // Fail-soft, and deliberately NOT 'unmeasured' — see the module header. + setSnapshot({ status: 'unavailable', reading: null }); + }); + return () => { + cancelled = true; + }; + }, [enabled, base, reloadToken]); + + // A disabled hook HAS no reading, so `idle` is DERIVED here rather than + // written back from the effect: state React can compute is not state an + // effect should set, and deriving it also means a hook toggled off cannot + // keep serving the answer it happened to hold. + const effective: ReadRateSnapshot = enabled ? snapshot : { status: 'idle', reading: null }; + // Rebuilt rather than spread so the discriminated union survives into the + // caller and `reading` narrows on `status` without a cast. + if (effective.status === 'measured') return { status: 'measured', reading: effective.reading, refetch }; + return { status: effective.status, reading: null, refetch }; +} diff --git a/packages/app-shell/src/layout/ReadRateBanner.tsx b/packages/app-shell/src/layout/ReadRateBanner.tsx new file mode 100644 index 0000000000..da2ac1f669 --- /dev/null +++ b/packages/app-shell/src/layout/ReadRateBanner.tsx @@ -0,0 +1,129 @@ +/** + * ReadRateBanner — the environment admin's standing read-rate report + * (objectui#9954; ruling on cloud#2333, batch #164 item 3). + * + * ## What raises it + * + * `readRate.state === 'anomalous'` on `GET /api/v1/usage/storage`, and nothing + * else. The control plane took that judgement once, in its own + * `read-rate-anomaly` module; this component READS the verdict and never + * re-derives it from the ratio and the threshold. Every other case — no reading + * at all, a reading that is under the line, an endpoint that could not be read — + * renders `null`, and {@link classifyReadRate} keeps those three apart so + * "nobody measured it" can never be rendered, logged or reasoned about as + * "measured and fine". + * + * ## Two anomalous cases, two sets of words + * + * An ABSENT `readsPerWrite` is not a missing number: it means the environment + * made no writes at all, so the ratio has no upper bound — the most severe + * reading there is, and the exact shape of the incident behind this work + * (cloud#2179: 101.16 billion rows read in nine days, unnoticed). Rendering it + * as an em dash, or hiding the banner because a number was missing, would hide + * the worst case. It gets its own title, its own sentence and the heavier tone. + * + * The threshold in both sentences comes from `ratioThreshold` on the wire. + * There is ⛔ no copy of the line in this repo. + * + * ## It is a REPORT, and may never become anything else + * + * Verbatim from the landed cloud module this reads: + * + * > Nothing downstream may turn this into a refusal, a throttle or a degraded + * > read. Option B was rejected on the record (cloud#2179), and this endpoint + * > is read-only in any case — but the banner it feeds must not become an + * > argument for the guardrail beside it. + * + * So: no gate, no throttle, no upgrade CTA, no disabled control. The copy says + * in as many words that nothing is limited or blocked. + * + * ## Where it mounts, and who sees it + * + * `ConsoleShell`, beside `ImpersonationBanner` — the one provider stack every + * console route passes through, so `/home` carries it too. `ConsoleLayout` was + * the alternative and is wrong here for the reason that module already records: + * it wraps only `/apps/*`, and an admin sitting on home would see nothing. The + * notification banner host (`ConsoleNotificationBanners`) was the other + * candidate and is wrong for a different reason — it renders notifications + * RAISED through the spec notification system, and this reading is neither a + * notification nor raised by anything in this app. + * + * The audience is the environment's own admin — the only person who can act on + * their own app's read pattern. Gated on `useWorkspaceAdminStatus`, which also + * keeps the request itself off every ordinary session rather than spending a + * 403 per page load. + */ +import { TriangleAlert } from 'lucide-react'; +import { cn } from '@object-ui/components'; +import { useWorkspaceAdminStatus } from '@object-ui/auth'; +import { useObjectTranslation, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n'; +import { useReadRateReading, classifyReadRate } from '../hooks/useReadRateReading.js'; + +export interface ReadRateBannerProps { + /** Override the resolved tenant runtime base (e.g. `/api/v1`). */ + apiBase?: string; + className?: string; +} + +export function ReadRateBanner({ apiBase, className }: ReadRateBannerProps) { + const { t } = useObjectTranslation(); + const locale = useDisplayLocale(); + const { isAdmin } = useWorkspaceAdminStatus(); + const snapshot = useReadRateReading({ apiBase, enabled: isAdmin }); + + const bannerCase = classifyReadRate(snapshot); + const reading = snapshot.status === 'measured' ? snapshot.reading : null; + + // Below every hook, so hook order is stable as the reading arrives. + if (!reading || (bannerCase !== 'anomalous-no-writes' && bannerCase !== 'anomalous-ratio')) { + return null; + } + + // `anomalous-no-writes` IS the absent ratio. Narrowed on the value itself so + // the number that reaches the copy is a real one, never a fallback standing in + // for a missing measurement. + const { readsPerWrite } = reading; + const noWrites = readsPerWrite === undefined; + const threshold = formatDisplayNumber(reading.ratioThreshold, { locale, maximumFractionDigits: 1 }); + + const title = noWrites + ? t('console.readRate.noWritesTitle', { + defaultValue: 'Reads with no writes at all in this environment', + }) + : t('console.readRate.ratioTitle', { defaultValue: 'Unusual read volume in this environment' }); + + const body = noWrites + ? t('console.readRate.noWrites', { + defaultValue: + 'Rows are being read while none at all are being written, so the read rate has no upper bound. This is the most severe reading. The platform flags anything above {{threshold}}. Nothing is limited or blocked; this is a report so the read pattern can be reviewed.', + threshold, + }) + : t('console.readRate.ratio', { + defaultValue: + 'Reads are running at {{ratio}} rows for every row written. The platform flags anything above {{threshold}}. Nothing is limited or blocked; this is a report so the read pattern can be reviewed.', + ratio: formatDisplayNumber(readsPerWrite, { locale, maximumFractionDigits: 1 }), + threshold, + }); + + return ( +
+
+ ); +} + +ReadRateBanner.displayName = 'ReadRateBanner'; diff --git a/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx new file mode 100644 index 0000000000..8f9e6c8c39 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx @@ -0,0 +1,163 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * ReadRateBanner (objectui#9954) — renders ONLY on the control plane's + * `anomalous` verdict, gives the no-writes reading its own words, takes the + * line from the wire, and stays a report. + * + * `classifyReadRate` is deliberately NOT mocked (only the data hook is), so + * these cases exercise the same classifier the component ships with. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { ReadRateSnapshot } from '../../hooks/useReadRateReading'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + // Interpolates `{{name}}` from the options object, the way the real i18next + // does for this copy's `{{ratio}}` / `{{threshold}}` holes. + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => + String(options?.[name] ?? ''), + ), + }), +})); + +vi.mock('@object-ui/auth', () => ({ useWorkspaceAdminStatus: vi.fn() })); +vi.mock('../../hooks/useReadRateReading', async (importOriginal) => ({ + ...(await importOriginal>()), + useReadRateReading: vi.fn(), +})); + +import { useWorkspaceAdminStatus } from '@object-ui/auth'; +import { useReadRateReading } from '../../hooks/useReadRateReading'; +import { ReadRateBanner } from '../ReadRateBanner'; + +const asMock = (fn: unknown) => fn as unknown as ReturnType; + +function setAdmin(isAdmin: boolean) { + asMock(useWorkspaceAdminStatus).mockReturnValue({ isAdmin, isResolved: true }); +} + +function setSnapshot(snapshot: ReadRateSnapshot) { + asMock(useReadRateReading).mockReturnValue({ ...snapshot, refetch: vi.fn() }); +} + +const ANOMALOUS_RATIO: ReadRateSnapshot = { + status: 'measured', + reading: { state: 'anomalous', readsPerWrite: 4210.5, ratioThreshold: 500 }, +}; +const ANOMALOUS_NO_WRITES: ReadRateSnapshot = { + status: 'measured', + reading: { state: 'anomalous', ratioThreshold: 500 }, +}; + +describe('ReadRateBanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + setAdmin(true); + }); + + it('renders on an anomalous ratio, showing the ratio and the line it was taken against', () => { + setSnapshot(ANOMALOUS_RATIO); + render(); + + const banner = screen.getByTestId('read-rate-banner'); + expect(banner).toHaveAttribute('data-read-rate-case', 'anomalous-ratio'); + expect(banner).toHaveTextContent('4,210.5'); + expect(banner).toHaveTextContent('500'); + }); + + // Property (1) — both "no reading at all" and "measured and under the line" + // render nothing, and the third silent answer (unreadable) does too. + it.each([ + ['the control plane reported no reading', { status: 'unmeasured', reading: null }], + ['the endpoint could not be read', { status: 'unavailable', reading: null }], + ['nothing is known yet', { status: 'loading', reading: null }], + [ + 'measured and under the line', + { status: 'measured', reading: { state: 'ok', readsPerWrite: 3, ratioThreshold: 500 } }, + ], + ] as Array<[string, ReadRateSnapshot]>)('renders nothing when %s', (_label, snapshot) => { + setSnapshot(snapshot); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + // Property (2) — an absent `readsPerWrite` is the WORST reading, so it gets + // its own title and its own sentence, is never hidden, and never renders as a + // missing value. + it('gives the no-writes reading its own words and never a dash', () => { + setSnapshot(ANOMALOUS_NO_WRITES); + render(); + + const banner = screen.getByTestId('read-rate-banner'); + expect(banner).toHaveAttribute('data-read-rate-case', 'anomalous-no-writes'); + const noWritesText = banner.textContent ?? ''; + expect(noWritesText).toMatch(/no upper bound/i); + expect(noWritesText).toMatch(/most severe/i); + expect(noWritesText).not.toMatch(/—/); + }); + + it('says something DIFFERENT for the no-writes case than for a ratio', () => { + setSnapshot(ANOMALOUS_RATIO); + const ratio = render(); + const ratioText = ratio.container.textContent ?? ''; + ratio.unmount(); + + setSnapshot(ANOMALOUS_NO_WRITES); + const noWrites = render(); + const noWritesText = noWrites.container.textContent ?? ''; + + expect(noWritesText).not.toBe(ratioText); + expect(ratioText).toMatch(/rows for every row written/i); + expect(noWritesText).not.toMatch(/rows for every row written/i); + }); + + // Property (3) — the line is DATA. Two different thresholds must produce two + // different renderings; a hard-coded copy of the line cannot do that. + it('takes the threshold from the reading, with no copy of the line in this repo', () => { + setSnapshot({ + status: 'measured', + reading: { state: 'anomalous', readsPerWrite: 900, ratioThreshold: 42 }, + }); + const first = render(); + expect(first.container).toHaveTextContent('42'); + first.unmount(); + + setSnapshot({ + status: 'measured', + reading: { state: 'anomalous', readsPerWrite: 900, ratioThreshold: 7 }, + }); + const second = render(); + expect(second.container).toHaveTextContent('7'); + expect(second.container).not.toHaveTextContent('42'); + }); + + // The landed cloud module's own words: nothing downstream may turn this into a + // refusal, a throttle or a degraded read. + it('is a report — no control to press, and the copy says nothing is limited', () => { + setSnapshot(ANOMALOUS_RATIO); + render(); + + const banner = screen.getByTestId('read-rate-banner'); + expect(banner.querySelector('button')).toBeNull(); + expect(banner.querySelector('a')).toBeNull(); + expect(banner).toHaveTextContent(/nothing is limited or blocked/i); + expect(banner.textContent ?? '').not.toMatch(/upgrade|throttl|blocked until|quota exceeded/i); + }); + + it('is for the environment admin — an ordinary session renders nothing and issues no request', () => { + setAdmin(false); + setSnapshot({ status: 'idle', reading: null }); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(asMock(useReadRateReading)).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); +}); diff --git a/packages/app-shell/src/layout/index.ts b/packages/app-shell/src/layout/index.ts index 5a49d1e9fb..f00edc726b 100644 --- a/packages/app-shell/src/layout/index.ts +++ b/packages/app-shell/src/layout/index.ts @@ -1,6 +1,7 @@ export { ConsoleLayout } from './ConsoleLayout.js'; export { ConsoleNotificationBanners } from './ConsoleNotificationBanners.js'; export { ImpersonationBanner } from './ImpersonationBanner.js'; +export { ReadRateBanner, type ReadRateBannerProps } from './ReadRateBanner.js'; export { AppHeader } from './AppHeader.js'; /** @deprecated Use `UnifiedSidebar` — see `AppSidebar`'s own JSDoc (objectui#5720, objectui#5817). */ export { AppSidebar } from './AppSidebar.js'; diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 15929bbb44..7a2cb21b0a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1855,6 +1855,13 @@ const ar = { records: "السجلات", recentRecords: "شوهد مؤخراً", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'حجم قراءة غير معتاد في هذه البيئة', + ratio: 'تتم قراءة {{ratio}} صف مقابل كل صف مكتوب. تُعلِّم المنصة كل ما يتجاوز {{threshold}}. لا يتم تقييد أو حظر أي شيء؛ هذا تقرير لمراجعة نمط القراءة.', + noWritesTitle: 'قراءات بدون أي كتابة في هذه البيئة', + noWrites: 'تتم قراءة صفوف دون كتابة أي صف على الإطلاق، لذا لا يوجد حد أعلى لمعدل القراءة. هذه هي أخطر قراءة. تُعلِّم المنصة كل ما يتجاوز {{threshold}}. لا يتم تقييد أو حظر أي شيء؛ هذا تقرير لمراجعة نمط القراءة.', + }, errors: { somethingWentWrong: "حدث خطأ ما", unexpectedError: "حدث خطأ غير متوقع أثناء عرض هذا المحتوى.", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 4a904eec21..d7f417f232 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1848,6 +1848,13 @@ const de = { records: "Datensätze", recentRecords: "Zuletzt angesehen", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'Ungewöhnliches Lesevolumen in dieser Umgebung', + ratio: 'Pro geschriebener Zeile werden {{ratio}} Zeilen gelesen. Die Plattform meldet alles über {{threshold}}. Es wird nichts begrenzt oder blockiert; dies ist ein Bericht, damit das Lesemuster geprüft werden kann.', + noWritesTitle: 'Lesevorgänge ganz ohne Schreibvorgänge in dieser Umgebung', + noWrites: 'Es werden Zeilen gelesen, aber überhaupt keine geschrieben, sodass die Leserate keine Obergrenze hat. Das ist der schwerwiegendste Messwert. Die Plattform meldet alles über {{threshold}}. Es wird nichts begrenzt oder blockiert; dies ist ein Bericht, damit das Lesemuster geprüft werden kann.', + }, errors: { somethingWentWrong: "Etwas ist schiefgelaufen", unexpectedError: "Beim Rendern dieser Ansicht ist ein unerwarteter Fehler aufgetreten.", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f20e20352a..0c12d34584 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2154,6 +2154,18 @@ const en = { older: 'Older', }, }, + // objectui#9954 — the environment admin's read-rate report (cloud#2333). + // Two anomalous cases, two sets of words: an ABSENT `readsPerWrite` means the + // environment wrote nothing at all, so the ratio is unbounded — the most + // severe reading, and never a missing number. `{{threshold}}` always comes + // from the wire; this repo holds no copy of the line. Report only — the copy + // states that nothing is limited or blocked, and must keep doing so. + readRate: { + ratioTitle: 'Unusual read volume in this environment', + ratio: 'Reads are running at {{ratio}} rows for every row written. The platform flags anything above {{threshold}}. Nothing is limited or blocked; this is a report so the read pattern can be reviewed.', + noWritesTitle: 'Reads with no writes at all in this environment', + noWrites: 'Rows are being read while none at all are being written, so the read rate has no upper bound. This is the most severe reading. The platform flags anything above {{threshold}}. Nothing is limited or blocked; this is a report so the read pattern can be reviewed.', + }, errors: { somethingWentWrong: 'Something went wrong', unexpectedError: 'An unexpected error occurred while rendering this view.', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index b67fae63ab..5836075f84 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1852,6 +1852,13 @@ const es = { records: "Registros", recentRecords: "Visto recientemente", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'Volumen de lectura inusual en este entorno', + ratio: 'Se leen {{ratio}} filas por cada fila escrita. La plataforma señala todo lo que supere {{threshold}}. No se limita ni se bloquea nada; es un informe para que se pueda revisar el patrón de lectura.', + noWritesTitle: 'Lecturas sin ninguna escritura en este entorno', + noWrites: 'Se están leyendo filas sin escribir ninguna, por lo que la tasa de lectura no tiene límite superior. Es la lectura más grave. La plataforma señala todo lo que supere {{threshold}}. No se limita ni se bloquea nada; es un informe para que se pueda revisar el patrón de lectura.', + }, errors: { somethingWentWrong: "Algo salió mal", unexpectedError: "Ocurrió un error inesperado al renderizar esta vista.", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index d64f5638ac..94e564e3fe 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1850,6 +1850,13 @@ const fr = { records: "Enregistrements", recentRecords: "Récemment consultés", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'Volume de lecture inhabituel dans cet environnement', + ratio: '{{ratio}} lignes sont lues pour chaque ligne écrite. La plateforme signale tout ce qui dépasse {{threshold}}. Rien n’est limité ni bloqué ; il s’agit d’un rapport permettant d’examiner le schéma de lecture.', + noWritesTitle: 'Lectures sans aucune écriture dans cet environnement', + noWrites: 'Des lignes sont lues alors qu’aucune n’est écrite, de sorte que le taux de lecture n’a aucune limite supérieure. C’est la mesure la plus grave. La plateforme signale tout ce qui dépasse {{threshold}}. Rien n’est limité ni bloqué ; il s’agit d’un rapport permettant d’examiner le schéma de lecture.', + }, errors: { somethingWentWrong: "Quelque chose s'est mal passé", unexpectedError: "Une erreur inattendue est survenue lors du rendu de cette vue.", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 5199ceef9f..50c1b7cab3 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1850,6 +1850,13 @@ const ja = { records: "レコード", recentRecords: "最近閲覧", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'この環境の読み取り量が異常です', + ratio: '書き込み 1 行あたり {{ratio}} 行を読み取っています。プラットフォームは {{threshold}} を超える比率を検出対象とします。制限やブロックは一切行われていません。読み取りパターンを確認するための報告です。', + noWritesTitle: 'この環境は読み取りのみで書き込みがまったくありません', + noWrites: '行の読み取りはある一方で書き込みがまったくないため、読み取り比率に上限がありません。これは最も深刻な読み取り値です。プラットフォームは {{threshold}} を超える比率を検出対象とします。制限やブロックは一切行われていません。読み取りパターンを確認するための報告です。', + }, errors: { somethingWentWrong: "問題が発生しました", unexpectedError: "このビューのレンダリング中に予期しないエラーが発生しました。", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 67577be735..0318121d3e 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1848,6 +1848,13 @@ const ko = { records: "레코드", recentRecords: "최근 본 항목", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: '이 환경의 읽기 양이 비정상적입니다', + ratio: '쓰기 1행당 {{ratio}}행을 읽고 있습니다. 플랫폼은 {{threshold}} 초과 비율을 표시합니다. 제한하거나 차단하는 것은 없습니다. 읽기 패턴을 검토할 수 있도록 알리는 보고입니다.', + noWritesTitle: '이 환경은 읽기만 있고 쓰기가 전혀 없습니다', + noWrites: '행을 읽고 있지만 쓰기가 전혀 없어 읽기 비율에 상한이 없습니다. 가장 심각한 수치입니다. 플랫폼은 {{threshold}} 초과 비율을 표시합니다. 제한하거나 차단하는 것은 없습니다. 읽기 패턴을 검토할 수 있도록 알리는 보고입니다.', + }, errors: { somethingWentWrong: "문제가 발생했습니다", unexpectedError: "이 뷰를 렌더링하는 중 예기치 않은 오류가 발생했습니다.", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index f0b15ec16f..abcde70e92 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1847,6 +1847,13 @@ const pt = { records: "Registros", recentRecords: "Vistos recentemente", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'Volume de leitura incomum neste ambiente', + ratio: 'São lidas {{ratio}} linhas para cada linha gravada. A plataforma sinaliza tudo acima de {{threshold}}. Nada está sendo limitado ou bloqueado; este é um relatório para que o padrão de leitura possa ser revisado.', + noWritesTitle: 'Leituras sem nenhuma gravação neste ambiente', + noWrites: 'Linhas estão sendo lidas sem que nenhuma seja gravada, portanto a taxa de leitura não tem limite superior. Esta é a leitura mais grave. A plataforma sinaliza tudo acima de {{threshold}}. Nada está sendo limitado ou bloqueado; este é um relatório para que o padrão de leitura possa ser revisado.', + }, errors: { somethingWentWrong: "Algo deu errado", unexpectedError: "Ocorreu um erro inesperado ao renderizar esta visualização.", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8bdacb6c2e..ead273b700 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1860,6 +1860,13 @@ const ru = { records: "Записи", recentRecords: "Недавно просмотренные", }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: 'Необычный объём чтения в этой среде', + ratio: 'На каждую записанную строку читается {{ratio}} строк. Платформа отмечает всё, что выше {{threshold}}. Ничто не ограничивается и не блокируется; это отчёт, чтобы можно было проверить схему чтения.', + noWritesTitle: 'Чтение вообще без записи в этой среде', + noWrites: 'Строки читаются, но не записывается ни одной, поэтому у скорости чтения нет верхней границы. Это самый серьёзный показатель. Платформа отмечает всё, что выше {{threshold}}. Ничто не ограничивается и не блокируется; это отчёт, чтобы можно было проверить схему чтения.', + }, errors: { somethingWentWrong: "Что-то пошло не так", unexpectedError: "При отображении этого представления произошла непредвиденная ошибка.", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index b68797f23f..b1e1b7aebb 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1921,6 +1921,13 @@ const zh = { older: '更早', }, }, + // objectui#9954 — see the `en` pack for what raises each case. + readRate: { + ratioTitle: '本环境读取量异常', + ratio: '每写入 1 行就读取 {{ratio}} 行。平台会标记高于 {{threshold}} 的比率。没有任何东西被限制或阻断;这只是一份报告,便于检查读取方式。', + noWritesTitle: '本环境只有读取、完全没有写入', + noWrites: '有行被读取,却完全没有任何写入,因此读取比率没有上限。这是最严重的读数。平台会标记高于 {{threshold}} 的比率。没有任何东西被限制或阻断;这只是一份报告,便于检查读取方式。', + }, errors: { somethingWentWrong: '出错了', unexpectedError: '渲染此视图时发生意外错误。', From 591a4f507fb8a65ebd9bbe3e1cd7f794ac531fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 06:37:27 +0000 Subject: [PATCH 2/4] test(app-shell): assert the no-writes banner EXISTS before comparing its copy Measured under ablation: hiding the banner when `readsPerWrite` is absent left this case green, because an empty render is trivially "different from the ratio copy". The presence assertion makes the comparison mean what its name claims. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../app-shell/src/layout/__tests__/ReadRateBanner.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx index 8f9e6c8c39..89219a75f8 100644 --- a/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx @@ -112,6 +112,10 @@ describe('ReadRateBanner', () => { const noWrites = render(); const noWritesText = noWrites.container.textContent ?? ''; + // Asserted before the comparison: an empty render would satisfy "different + // from the ratio copy" while proving nothing — and a hidden no-writes banner + // is the exact defect this case exists to catch. + expect(noWrites.getByTestId('read-rate-banner')).toBeInTheDocument(); expect(noWritesText).not.toBe(ratioText); expect(ratioText).toMatch(/rows for every row written/i); expect(noWritesText).not.toMatch(/rows for every row written/i); From 41710d21a8a416e9e8e04eee52ff3708ec35aa11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 07:05:19 +0000 Subject: [PATCH 3/4] docs(app-shell): document the read-rate report and what it is NOT AGENTS.md #2. Sits beside the Notifications section on purpose: both surfaces are "banners" and only one of them is raised through the notification system. The table states what renders for each reading, and names the three silent answers as three different facts rather than one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- packages/app-shell/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index fe847122a2..4db6c60f27 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -130,6 +130,37 @@ delegate) and `ConsoleNotificationBanners` (the banners, guarded by banners instead of throwing). See the [notifications guide](https://objectui.org/docs/guide/notifications). +## Read-rate report (environment admin) + +`ConsoleShell` also mounts ``, beside the impersonation +indicator, so every console route carries it — including `/home`, which has its +own layout. It is **not** a notification banner: nothing in this app raises it. +It renders the tenant runtime's own verdict, read by `useReadRateReading` from +the optional `readRate` key on `GET /api/v1/usage/storage`. + +| the reading | what renders | +| --- | --- | +| `state: 'anomalous'`, with a `readsPerWrite` | the ratio, and the line it was measured against | +| `state: 'anomalous'`, `readsPerWrite` ABSENT | the no-writes reading: an unbounded ratio, its own words, the heavier tone | +| `state: 'ok'` | nothing — measured, and under the line | +| no `readRate` at all | nothing — the control plane reported NO reading | +| the endpoint could not be read | nothing | + +The last three all render nothing and are **three different facts**; +`classifyReadRate` keeps them apart, because "why does my environment show no +banner" has more than one answer and one of them is *nobody has measured it*. + +Two more properties of that contract are load-bearing. An absent `readsPerWrite` +means the environment made no writes at all, so the ratio has no upper bound — +it is the most severe reading there is, never a missing number to hide or dash +out. And the threshold is **data**: it is rendered from `ratioThreshold` on the +wire, the verdict is never re-derived from it, and this package holds no copy of +the line. + +It is a **report**. It never refuses, throttles or degrades anything, and the +copy says so. It is shown only to a workspace admin, who is also the only +session that issues the request. + ## Components ### AppShell From 5254b2356661eae42f1fd0c1a85ea88a377b9aed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 08:10:27 +0000 Subject: [PATCH 4/4] test(app-shell): inherit the real @object-ui/auth surface in the banner mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:vi-mock-inherit` refuses a hand-listed factory: it freezes the mock to the exports written that day, so the next export any module in this file's import graph reads at module scope kills the file during COLLECTION — zero failed assertions, reading green. Why my own pre-push run of that gate said exit 0: it walks `git ls-files`, and the file was still UNTRACKED when I ran it. Measured just now on this head, in one command: an untracked copy carrying the identical violation is reported 0 times while the tracked offender is reported once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .../app-shell/src/layout/__tests__/ReadRateBanner.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx index 89219a75f8..3d06998da5 100644 --- a/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx +++ b/packages/app-shell/src/layout/__tests__/ReadRateBanner.test.tsx @@ -26,7 +26,10 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ }), })); -vi.mock('@object-ui/auth', () => ({ useWorkspaceAdminStatus: vi.fn() })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useWorkspaceAdminStatus: vi.fn(), +})); vi.mock('../../hooks/useReadRateReading', async (importOriginal) => ({ ...(await importOriginal>()), useReadRateReading: vi.fn(),