Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/9954-read-rate-banner.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ReadRateBanner />`, 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
Expand Down
10 changes: 10 additions & 0 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -173,6 +174,15 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) {
header it warns about. Renders null on every ordinary
session. */}
<ImpersonationBanner />
{/* 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. */}
<ReadRateBanner />
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
{/* ADR-0069 — full-screen gate (expired password / required MFA) above all routes */}
<RemediationOverlay />
Expand Down
190 changes: 190 additions & 0 deletions packages/app-shell/src/hooks/__tests__/useReadRateReading.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions packages/app-shell/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading