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
11 changes: 11 additions & 0 deletions .changeset/11002-storage-usage-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': minor
---

The console asks `GET /api/v1/usage/storage` only when the runtime serves it (objectui#11002). On a self-hosted or open-source runtime, an environment admin's console used to request that cloud-only endpoint on every shell mount and log a 404 each time. Now neither the storage-capacity banner nor the read-rate report asks unless the runtime config's `features.storageUsage` is `true`. The cloud distribution sends that key exactly when it mounts the endpoint (cloud#2481), and every other runtime sends no key.

**Clause-②: yes (widening)** — the exported `RuntimeFeatures` type gains one optional member, `storageUsage?: boolean`, so `AppShellRuntimeConfig.features` and the value `getRuntimeConfig()` returns carry it too. It is `false` by default and after any runtime-config payload whose `features.storageUsage` is not the literal `true` (absent, `false`, `'true'` and `1` all read as `false`). It is `true` only when the payload sends `true`. As with the other feature keys, a payload with no `features` object leaves the current value. Nothing else on the package entry changes: no export is added or removed, and no existing member changes type.

**Behaviour change.** `ConsoleShell`'s two banners request the endpoint only when the viewer is the environment admin, as before, and `features.storageUsage` is on. Both read the same flag and still share one request. A cloud runtime older than the one that serves the key (cloud#2517) sends no key, so its admins see neither banner until that runtime is upgraded.

Not published: the accessor the banners read, `isStorageUsageServed()`. Like its siblings `isMarketplaceEnabled()` and `isAiStudioEnabled()`, it ships inside `dist/` but is not exported from the package entry. `useStorageUsageReading` and `useReadRateReading` are unchanged and keep their `enabled = true` default.
2 changes: 1 addition & 1 deletion content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Vite dev server proxies `/api/*` to the backend. See [Running with a Real Backen

**2. Server-pushed runtime config — everything else.** Before React mounts, the console
resolves `/api/v1/runtime/config` from that server and applies it: product branding, feature
flags (marketplace, AI Studio, SSO, custom domain), and the cloud URL. Operators configure
flags (marketplace, AI Studio, SSO, custom domain, the storage-usage reading), and the cloud URL. Operators configure
these on the **server**, not in the SPA, which is why changing them needs no console rebuild.

Apps, objects and views themselves are metadata fetched over HTTP — discovered at connect
Expand Down
7 changes: 7 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ Both banners read that endpoint through one shared reader, so a page load
issues one request for the two of them. The storage banner has the same
audience and the same gate as the read-rate report.

Neither banner asks a runtime that does not serve that endpoint. Both read one
flag from the server-pushed runtime config, `features.storageUsage`, and ask
only when it is `true` (objectui#11002). The cloud distribution sends it
exactly when it mounts the endpoint; every other runtime sends no key, which
reads as off, so a self-hosted admin's page load issues no request and logs no
404. Only the literal `true` turns it on.

## Components

### AppShell
Expand Down
12 changes: 8 additions & 4 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,14 @@ function ConsoleShellProviders({ children }: { children: ReactNode }) {
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. */}
never even issues the request, nor does any session on a
runtime whose config does not serve `storageUsage`
(objectui#11002). */}
<ReadRateBanner />
{/* objectui#10439 — the environment admin's storage-capacity
banner, beside the read-rate report and behind the same
admin gate; the two read one `/usage/storage` response.
two gates (the admin, and the runtime serving
`storageUsage`); the two read one `/usage/storage` response.
Renders null unless the runtime's own verdict says `warn`
(80% and up) or `blocked` (uploads and imports refused). */}
<StorageUsageBanner />
Expand Down Expand Up @@ -284,8 +287,9 @@ function ConnectedShellInner({ children }: { children: ReactNode }) {
if (adapter) lastLanguage.current = language;

if (!adapter || isAuthLoading) return <LoadingFallback />;
// Expose the adapter via SchemaRendererContext so descendant hooks like
// useDiscovery() (used to gate the global AI chatbot) can resolve it.
// Expose the adapter via SchemaRendererContext so descendant hooks that fall
// back to the context's `dataSource` (`useViewData`, `useElementDataSource`)
// can resolve it.
return (
<SchemaRendererProvider dataSource={adapter} apiFetch={apiProviderFetch}>
<MetadataProvider key={language} adapter={adapter}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* `ConsoleShell` asks `GET /api/v1/usage/storage` only when the runtime serves
* it (objectui#11002, ruled on the card as letter A).
*
* The endpoint is a cloud one: the cloud distribution's `RuntimeConfigPlugin`
* serves `features.storageUsage: true` exactly when its composition mounts the
* endpoint plugin, and every other runtime sends no key at all. Before this
* gate the shell's two banners (`ReadRateBanner`, `StorageUsageBanner`) asked
* on every admin page load regardless, so a self-hosted admin logged a 404 per
* shell mount.
*
* What is pinned, against the real shell, the real runtime-config singleton
* (filled through `initRuntimeConfig`, so the payload mapping is on the path)
* and the real shared endpoint reader — only the admin status and the network
* are stood in:
* - an admin on a runtime that sends no `storageUsage` key issues NO request;
* - so does an admin on a runtime that sends `storageUsage: false`;
* - an admin on a runtime that serves it issues ONE request, shared by both
* banners, and the storage banner still renders on a `warn` verdict;
* - a non-admin issues no request even when the runtime serves it.
*
* Every zero below is read after the same settle that sees the one request in
* the served case, so a zero here is a reading, not an unflushed effect.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

// Same two stubs the sibling ConsoleShell tests use: sonner is a global toast
// surface, and the ADR-0069 remediation gate needs an AuthProvider above it.
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), {
info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(),
dismiss: vi.fn(), custom: vi.fn(), loading: vi.fn(), promise: vi.fn(),
}),
Toaster: () => null,
}));
vi.mock('../RemediationOverlay', () => ({ RemediationOverlay: () => null }));
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: vi.fn(),
}));

import { useWorkspaceAdminStatus } from '@object-ui/auth';
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config';
import { ConsoleShell } from '../ConsoleShell';

const asMock = (fn: unknown) => fn as unknown as ReturnType<typeof vi.fn>;

// `createAuthenticatedFetch` reads `response.headers` to adopt a rotated
// session token, so a stub without them is not a Response that lane can use.
function respond(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(),
json: async () => body,
} as unknown as Response;
}

/** The endpoint's answer: a `warn` storage verdict and an anomalous read rate. */
const USAGE = {
state: 'warning',
usedMb: 850,
limitMb: 1024,
warn: true,
blocked: false,
readRate: { state: 'anomalous', readsPerWrite: 700, ratioThreshold: 500 },
};

/**
* Stand in for the network: the runtime config carries `features` as given,
* `/usage/storage` answers {@link USAGE} and is counted, anything else is 404.
*/
function stubNetwork(features: Record<string, unknown>): string[] {
const storageRequests: string[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith('/api/v1/runtime/config')) return respond({ features });
if (url.endsWith('/usage/storage')) {
storageRequests.push(url);
return respond(USAGE);
}
return respond({}, 404);
}),
);
return storageRequests;
}

function setAdmin(isAdmin: boolean) {
asMock(useWorkspaceAdminStatus).mockReturnValue({ isAdmin, isResolved: true });
}

/** Boot the runtime config against `features`, then mount the real shell. */
async function mountShell(features: Record<string, unknown>) {
const storageRequests = stubNetwork(features);
await initRuntimeConfig();
render(
<MemoryRouter initialEntries={['/home']}>
<ConsoleShell>
<div data-testid="route">route</div>
</ConsoleShell>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('route')).toBeInTheDocument());
// Let every mount effect run and any request it started settle.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
return storageRequests;
}

/** What a runtime that is not the cloud distribution sends: no `storageUsage` key. */
const SELF_HOSTED = { marketplace: true, installLocal: false, aiStudio: true };

describe('ConsoleShell asks for the storage reading only when the runtime serves it (objectui#11002)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
resetRuntimeConfigForTesting();
vi.unstubAllGlobals();
});

it('an admin on a runtime that sends no storageUsage key issues no request', async () => {
setAdmin(true);
const storageRequests = await mountShell(SELF_HOSTED);

expect(storageRequests).toHaveLength(0);
expect(screen.queryByTestId('storage-usage-banner')).toBeNull();
expect(screen.queryByTestId('read-rate-banner')).toBeNull();
});

it('an admin on a runtime that sends storageUsage: false issues no request', async () => {
setAdmin(true);
const storageRequests = await mountShell({ ...SELF_HOSTED, storageUsage: false });

expect(storageRequests).toHaveLength(0);
});

it('an admin on a runtime that serves it issues ONE request, shared by both banners', async () => {
setAdmin(true);
const storageRequests = await mountShell({ ...SELF_HOSTED, storageUsage: true });

await waitFor(() =>
expect(screen.getByTestId('storage-usage-banner')).toHaveAttribute('data-storage-usage-case', 'warning'),
);
expect(screen.getByTestId('read-rate-banner')).toHaveAttribute('data-read-rate-case', 'anomalous-ratio');
expect(storageRequests).toHaveLength(1);
});

it('a non-admin issues no request even when the runtime serves it', async () => {
setAdmin(false);
const storageRequests = await mountShell({ ...SELF_HOSTED, storageUsage: true });

expect(storageRequests).toHaveLength(0);
expect(screen.queryByTestId('storage-usage-banner')).toBeNull();
});
});
11 changes: 9 additions & 2 deletions packages/app-shell/src/layout/ReadRateBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,17 @@
* 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.
* 403 per page load. The request is gated as well on
* {@link isStorageUsageServed} — the runtime's own `features.storageUsage`,
* the one boolean `StorageUsageBanner` reads too — so a runtime that does not
* serve the endpoint is not spent a 404 per page load either (objectui#11002).
*/
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';
import { isStorageUsageServed } from '../runtime-config.js';

export interface ReadRateBannerProps {
/** Override the resolved tenant runtime base (e.g. `/api/v1`). */
Expand All @@ -69,7 +73,10 @@ export function ReadRateBanner({ apiBase, className }: ReadRateBannerProps) {
const { t } = useObjectTranslation();
const locale = useDisplayLocale();
const { isAdmin } = useWorkspaceAdminStatus();
const snapshot = useReadRateReading({ apiBase, enabled: isAdmin });
// Asked only when the runtime serves the endpoint (objectui#11002): the one
// boolean `StorageUsageBanner` gates on too, so neither banner probes a
// runtime that would answer 404.
const snapshot = useReadRateReading({ apiBase, enabled: isAdmin && isStorageUsageServed() });

const bannerCase = classifyReadRate(snapshot);
const reading = snapshot.status === 'measured' ? snapshot.reading : null;
Expand Down
12 changes: 11 additions & 1 deletion packages/app-shell/src/layout/StorageUsageBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,20 @@
* environment's administrator, gated on `useWorkspaceAdminStatus` — the same
* gate the read-rate report uses — which also keeps the request itself off every
* ordinary session. Both banners read one response (`readStorageUsage`).
*
* Neither asks a runtime that does not serve that response: the request is
* also gated on {@link isStorageUsageServed}, the runtime's own
* `features.storageUsage`, which only the cloud distribution that mounts the
* endpoint sends (objectui#11002). One boolean for both banners, off by
* default, so a self-hosted admin's page load issues no request at all.
*/
import { HardDrive } from 'lucide-react';
import { cn } from '@object-ui/components';
import { useWorkspaceAdminStatus } from '@object-ui/auth';
import { useObjectTranslation, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
import { useStorageUsageReading, classifyStorageUsage } from '../hooks/useStorageUsageReading.js';
import { cloudConsoleUrl } from '../console/marketplace/marketplaceApi.js';
import { isStorageUsageServed } from '../runtime-config.js';

export interface StorageUsageBannerProps {
/** Override the resolved tenant runtime base (e.g. `/api/v1`). */
Expand All @@ -53,7 +60,10 @@ export function StorageUsageBanner({ apiBase, className }: StorageUsageBannerPro
const { t } = useObjectTranslation();
const locale = useDisplayLocale();
const { isAdmin } = useWorkspaceAdminStatus();
const snapshot = useStorageUsageReading({ apiBase, enabled: isAdmin });
// Asked only when the runtime serves the endpoint (objectui#11002): the one
// boolean `ReadRateBanner` gates on too, so neither banner probes a runtime
// that would answer 404.
const snapshot = useStorageUsageReading({ apiBase, enabled: isAdmin && isStorageUsageServed() });

const view = classifyStorageUsage(snapshot);
// Below every hook, so hook order is stable as the reading arrives.
Expand Down
59 changes: 58 additions & 1 deletion packages/app-shell/src/runtime-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { initRuntimeConfig, getRuntimeConfig, getPlatformStage, isAiStudioEnabled, isMarketplaceEnabled, getClientErrorReporting, resetRuntimeConfigForTesting } from './runtime-config.js';
import { initRuntimeConfig, getRuntimeConfig, getPlatformStage, isAiStudioEnabled, isMarketplaceEnabled, isStorageUsageServed, getClientErrorReporting, resetRuntimeConfigForTesting } from './runtime-config.js';

function mockConfig(features: Record<string, unknown>) {
vi.stubGlobal('fetch', vi.fn(async () => ({
Expand Down Expand Up @@ -161,6 +161,63 @@ describe('runtime-config isAiStudioEnabled (objectui#5577)', () => {
});
});

/**
* `features.storageUsage` — does this runtime serve `GET /api/v1/usage/storage`?
* (objectui#11002)
*
* A composition fact, not a plan one: the cloud distribution serves `true`
* exactly when it mounts the endpoint, and every other runtime sends no key.
* The two console banners that read that endpoint ask it only when this is on,
* so it is OFF unless the server sends the literal `true` — the runtimes that
* leave the question unanswered are exactly the ones that would answer the
* request with a 404. `'true'` and `1` are payloads a consumer should not teach
* itself to accept.
*/
describe('runtime-config storageUsage (objectui#11002)', () => {
it('is OFF before init', () => {
resetRuntimeConfigForTesting();
expect(getRuntimeConfig().features.storageUsage).toBe(false);
expect(isStorageUsageServed()).toBe(false);
});

it('is ON when the server sends the literal true', async () => {
mockConfig({ storageUsage: true });
await initRuntimeConfig();
expect(getRuntimeConfig().features.storageUsage).toBe(true);
expect(isStorageUsageServed()).toBe(true);
});

it.each([
['absent (a self-hosted runtime)', { marketplace: true, installLocal: false }],
['false', { storageUsage: false }],
["the string 'true'", { storageUsage: 'true' }],
['the number 1', { storageUsage: 1 }],
])('is OFF when the key is %s', async (_label, features) => {
mockConfig(features);
await initRuntimeConfig();
expect(getRuntimeConfig().features.storageUsage).toBe(false);
expect(isStorageUsageServed()).toBe(false);
});

it('is OFF when the config fetch itself fails', async () => {
vi.stubGlobal('fetch', vi.fn(async () => {
throw new Error('network down');
}));
await initRuntimeConfig();
expect(isStorageUsageServed()).toBe(false);
});

it('withdraws it when a later fetch no longer serves it', async () => {
mockConfig({ storageUsage: true });
await initRuntimeConfig();
expect(isStorageUsageServed()).toBe(true);

mockConfig({ marketplace: true });
await initRuntimeConfig();
expect(isStorageUsageServed()).toBe(false);
});
});

describe('runtime-config platform stage', () => {
it('defaults to preview before init (badge shows out of the box)', () => {
resetRuntimeConfigForTesting();
Expand Down
Loading
Loading