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
71 changes: 71 additions & 0 deletions scripts/generate-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,77 @@ const MVP_COMPONENTS: Record<string, ComponentManifestDraft> = {
},
capabilities: { supports_compare: false, supports_export: true, supports_fullscreen: true, supports_time_range: true },
},
// OMN-20006: the Usage page's readers. Each renders its exposure's rows as
// served (the same renderer the local Usage page uses), and declaring the
// topic here is what the omnibase_infra exposure-reader-coverage gate
// (OMN-17199) resolves, so the projections need no `consumers: none` opt-out.
'usage-by-model-day': {
name: 'usage-by-model-day',
displayName: 'Usage by Model and Day',
description:
'Tokens in, tokens out and measured cost per model per UTC day, with the count of calls whose cost was not measured. Every value is served by node_projection_usage_by_model_day; the widget sums nothing and shows Not measured, never 0, for a cost nobody measured.',
category: 'cost',
version: '1.0.0',
implementationKey: 'usage/UsageByModelDayWidget',
projectionSchema: {
type: 'object',
required: ['tenant_id', 'usage_day', 'model_id', 'input_tokens', 'output_tokens', 'call_count'],
properties: {
tenant_id: { type: 'string', description: "The reading tenant; the exposure's tenant_column, so the server scopes the read." },
usage_day: { type: 'string', format: 'date', description: 'UTC day of the calls.' },
model_id: { type: 'string', description: 'Model the calls ran on.' },
input_tokens: { type: 'integer', description: 'Tokens in, summed by the projection over every call.' },
output_tokens: { type: 'integer', description: 'Tokens out, summed by the projection over every call.' },
cost_usd: { type: ['number', 'string'], description: 'Cost of every call, estimates included. Not rendered.' },
measured_cost_usd: { type: ['number', 'string', 'null'], description: 'Cost of the calls whose cost was measured; NULL when none was.' },
unmeasured_call_count: { type: 'integer', description: 'Calls whose cost was estimated or not recorded.' },
call_count: { type: 'integer', description: 'Every call for the day and model.' },
},
},
dataSources: [projectionSource(TOPICS.usageByModelDay)],
events: { emits: [], consumes: [] },
defaultSize: { w: 12, h: 4 },
minSize: { w: 6, h: 3 },
maxSize: { w: 12, h: 10 },
emptyState: {
message: 'No usage rows yet',
hint: 'Rows appear once llm-call-completed events reach node_projection_usage_by_model_day.',
},
capabilities: { supports_compare: false, supports_export: true, supports_fullscreen: true, supports_time_range: false },
},
'usage-savings-series': {
name: 'usage-savings-series',
displayName: 'Savings per Day',
description:
"Savings per UTC day against the one baseline, named in the series title, from node_projection_metering_summary's day rows. A modelled counterfactual: the runs' tokens priced at the baseline's list price. Null savings render Not measured, never 0.",
category: 'cost',
version: '1.0.0',
implementationKey: 'usage/SavingsSeriesWidget',
projectionSchema: {
type: 'object',
required: ['tenant_id', 'window_kind', 'window_start', 'baseline_model'],
properties: {
tenant_id: { type: 'string', description: "The reading tenant; the exposure's tenant_column." },
window_kind: { type: 'string', enum: ['day', 'all'], description: 'Only day rows are drawn; the all row is the Overview headline.' },
window_start: { type: 'string', description: 'UTC day of a day row; empty on the all row.' },
baseline_model: { type: 'string', description: 'The baseline the savings are stated against.' },
baseline_state: { type: 'string', enum: ['resolved', 'unresolved'] },
runs_total: { type: 'integer' },
savings_usd: { type: ['number', 'string', 'null'], description: 'NULL when no run of the day was measured.' },
as_of: { type: 'string', format: 'date-time' },
},
},
dataSources: [projectionSource(TOPICS.meteringSummary)],
events: { emits: [], consumes: [] },
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
maxSize: { w: 12, h: 10 },
emptyState: {
message: 'No savings rows yet',
hint: 'Rows appear once node_projection_metering_summary serves its day rows.',
},
capabilities: { supports_compare: false, supports_export: true, supports_fullscreen: true, supports_time_range: false },
},
'intent-distribution': {
name: 'intent-distribution',
displayName: 'Intent Distribution',
Expand Down
7 changes: 7 additions & 0 deletions shared/types/palette-visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,11 @@ export const PALETTE_CLASSIFICATION: Record<string, PaletteClassification> = {
// rendering a real row is the honest answer; the widget's own empty state
// carries the same caveat for anyone who sees zero.
'work-events': { paletteVisibility: 'visible', authorityLabel: 'projection-backed', probe: 'work.events.v1=200/1r backing=bus (dev lane projection-api, 2026-09-04T13:32:17Z)' },
// OMN-20006: the Usage page's two readers, probed read-only on the dev lane
// projection API. usage-by-model-day answers 200 with no rows yet (no usage
// writer runs there), so it is visible with a truthful empty state;
// metering-summary is not exposed until omnimarket#3368 lands, so its series
// is hidden until a re-probe answers 200.
'usage-by-model-day': { paletteVisibility: 'visible', authorityLabel: 'degraded', probe: 'usage-by-model-day.v1=200/0r (dev lane projection-api, 2026-10-04T15:21:46Z)' },
'usage-savings-series': { paletteVisibility: 'hidden', authorityLabel: 'hidden', probe: 'metering-summary.v1=404 unknown_topic (dev lane projection-api, 2026-10-04T15:21:46Z)' },
};
14 changes: 14 additions & 0 deletions shared/types/topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,20 @@ export const TOPICS = {
* reader so the OMN-17199 exposure-reader gate resolves one.
*/
workEvents: 'onex.snapshot.projection.work.events.v1',
/**
* Usage by model and day -- tokens in and out, measured cost and the count of
* calls whose cost was not measured, per tenant, UTC day and model, folded by
* omnimarket's node_projection_usage_by_model_day (OMN-19978). Tenant-scoped
* and `bus_backed: true`; rendered by the local Usage page and declared as read
* by the usage-by-model-day component (OMN-20006).
*/
usageByModelDay: 'onex.snapshot.projection.usage-by-model-day.v1',
/**
* Metering summary -- one `day` row per UTC day and one `all` row per baseline
* model from node_projection_metering_summary, the one savings definition.
* The Usage page's daily savings series reads the day rows (OMN-20006, US-3).
*/
meteringSummary: 'onex.snapshot.projection.metering-summary.v1',
/**
* Inference response text projection — latest ModelLlmInferenceResponse rows
* sourced from the bus topic onex.evt.omnibase-infra.inference-response.v1.
Expand Down
2 changes: 2 additions & 0 deletions src/components/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,6 @@ export const componentImports: Record<string, ReturnType<typeof lazy>> = {
'consumer-flow/ConsumerFlowWidget': lazy(() => import('./consumer-flow/ConsumerFlowWidget')),
'session-replay/SessionReplayWidget': lazy(() => import('./session-replay/SessionReplayWidget')),
'work-events/WorkEventsWidget': lazy(() => import('./work-events/WorkEventsWidget')),
'usage/UsageByModelDayWidget': lazy(() => import('./usage/UsageByModelDayWidget')),
'usage/SavingsSeriesWidget': lazy(() => import('./usage/SavingsSeriesWidget')),
};
20 changes: 20 additions & 0 deletions src/components/dashboard/usage/SavingsSeriesWidget.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { MeteringDaySeries } from './SavingsSeriesWidget';

const meta: Meta<typeof MeteringDaySeries> = {
title: 'Dashboard / MeteringDaySeries',
component: MeteringDaySeries,
parameters: { layout: 'padded' },
};
export default meta;
type Story = StoryObj<typeof MeteringDaySeries>;

const day = (window_start: string, savings_usd: string | null, runs_total: number) => ({
tenant_id: 'tenant-a', window_kind: 'day', window_start, baseline_model: 'claude-sonnet-5-5',
baseline_state: 'resolved', runs_total, savings_usd, as_of: '2026-10-02T12:00:00Z',
});

export const Empty: Story = { args: { rows: [] } };
export const Populated: Story = {
args: { rows: [day('2026-09-30', '0.85', 4), day('2026-10-01', null, 1), day('2026-10-02', '1.20', 3)] },
};
71 changes: 71 additions & 0 deletions src/components/dashboard/usage/SavingsSeriesWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
// OMN-20006 Amendment 1, AC-US3: the daily savings series renders metering-summary.v1 day rows as served.
//
// Failure modes each test is written against:
// * a null savings renders 0 or $0 instead of "Not measured" (D6);
// * the baseline the savings are stated against is not named in the series title, or an unresolved baseline
// reads as a number (D7);
// * the all-time row is drawn as a day, or the page sums the days into a total (D8, TI1);
// * rows stated against two baselines are mixed under one title.
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MeteringDaySeries } from './SavingsSeriesWidget';

const day = (overrides: Record<string, unknown>) => ({
tenant_id: 'tenant-a',
window_kind: 'day',
window_start: '2026-10-01',
window_end: '2026-10-02',
as_of: '2026-10-02T12:00:00Z',
baseline_model: 'claude-sonnet-5-5',
pricing_manifest_version: '1',
baseline_state: 'resolved',
runs_total: 3,
savings_usd: '1.20',
...overrides,
});

describe('MeteringDaySeries', () => {
it('renders each served day\'s savings as served, with the baseline named in the series title (AC-US3)', () => {
render(<MeteringDaySeries rows={[
day({ window_kind: 'all', window_start: '', savings_usd: '9.99', runs_total: 7 }),
day({}),
day({ window_start: '2026-10-02', savings_usd: '0.40', runs_total: 2 }),
]} />);
const table = screen.getByRole('table', { name: 'Savings per day vs claude-sonnet-5-5' });
// The viewer reads the baseline in the visible column title, not only in the accessible name.
expect(within(table).getByRole('columnheader', { name: 'Savings vs claude-sonnet-5-5' })).toBeVisible();
const rows = within(table).getAllByRole('row').slice(1);
expect(rows.map((r) => r.textContent)).toEqual(['2026-10-01$1.203', '2026-10-02$0.402']);
expect(screen.queryByText(/9\.99/)).not.toBeInTheDocument();
expect(screen.queryByText(/total/i)).not.toBeInTheDocument();
});

it('renders Not measured for a day with no measured run, never 0 (AC-US3)', () => {
render(<MeteringDaySeries rows={[day({ savings_usd: null, runs_total: 1 })]} />);
expect(screen.getByText('Not measured')).toBeInTheDocument();
expect(screen.queryByText(/^\$?0(?:\.0+)?$/)).not.toBeInTheDocument();
});

it('renders Baseline unresolved when the day has no priced baseline', () => {
render(<MeteringDaySeries rows={[day({ baseline_state: 'unresolved', savings_usd: null })]} />);
expect(screen.getByText('Baseline unresolved')).toBeInTheDocument();
});

it('keeps each baseline\'s days under its own title', () => {
render(<MeteringDaySeries rows={[day({}), day({ baseline_model: 'gpt-6', savings_usd: '2.00' })]} />);
expect(within(screen.getByRole('table', { name: 'Savings per day vs gpt-6' })).getByText('$2.00')).toBeInTheDocument();
expect(within(screen.getByRole('table', { name: 'Savings per day vs claude-sonnet-5-5' })).getByText('$1.20')).toBeInTheDocument();
});

it('says the figure is modelled, not a baseline run', () => {
render(<MeteringDaySeries rows={[day({})]} />);
expect(screen.getByText(/Modelled/)).toBeInTheDocument();
});

it('renders a typed state when no day row is served yet', () => {
render(<MeteringDaySeries rows={[day({ window_kind: 'all', window_start: '' })]} />);
expect(screen.getByRole('status')).toHaveTextContent('No savings rows yet');
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
});
102 changes: 102 additions & 0 deletions src/components/dashboard/usage/SavingsSeriesWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// OMN-20006 Amendment 1 (US-3): savings per UTC day against the one baseline, from metering-summary.v1.
//
// node_projection_metering_summary serves one `day` row per UTC day and one `all` row per baseline model, from one
// fold, so the day rows are the daily series and the all row is the headline (OV-3, owned by the Overview). This
// renders the day rows only, as served: the browser sums nothing, so no total row exists here.
//
// This module is the declared reader of `onex.snapshot.projection.metering-summary.v1` in the component registry,
// which the omnibase_infra exposure-reader-coverage gate (OMN-17199) resolves readers from.
import { ComponentWrapper } from '../ComponentWrapper';
import { useProjectionSnapshotQuery } from '@/hooks/useProjectionSnapshotQuery';
import { TOPICS } from '@shared/types/topics';
import { formatUsd } from './UsageByModelDayWidget';

/** The subset of metering-summary.v1 columns the series renders. */
export interface MeteringSummaryRow {
tenant_id?: string;
window_kind?: string;
window_start?: string;
baseline_model?: string | null;
baseline_state?: string | null;
runs_total?: number | null;
savings_usd?: number | string | null;
as_of?: string;
}

/** A savings figure is a modelled counterfactual: the runs' tokens priced at the baseline's list price. */
export const SAVINGS_MODELLED = "Modelled: the runs' tokens priced at the baseline model's list price. The baseline never ran.";
export const NO_SAVINGS_ROWS = 'No savings rows yet: waits on metering-summary day rows';

function isMissing(value: unknown): boolean {
return value === null || value === undefined || value === '';
}

function savings(row: MeteringSummaryRow): string {
if (row.baseline_state === 'unresolved' || isMissing(row.baseline_model)) return 'Baseline unresolved';
return isMissing(row.savings_usd) ? 'Not measured' : formatUsd(row.savings_usd);
}

/** Day rows grouped by the baseline they are stated against, each group in served order. */
function byBaseline(rows: readonly unknown[]): Array<[string, MeteringSummaryRow[]]> {
const groups = new Map<string, MeteringSummaryRow[]>();
for (const value of rows) {
if (typeof value !== 'object' || value === null) continue;
const row = value as MeteringSummaryRow;
if (row.window_kind !== 'day') continue;
const baseline = isMissing(row.baseline_model) ? 'an unresolved baseline' : String(row.baseline_model);
groups.set(baseline, [...(groups.get(baseline) ?? []), row]);
}
return [...groups.entries()];
}

/**
* US-3: savings per day, the baseline named in each series' title. Named for the exposure it renders (as the Overview's
* MeteringTotalCard is), so a page that mounts it names no savings field (OMN-19980 R4).
*/
export function MeteringDaySeries({ rows }: { rows: readonly unknown[] }) {
const series = byBaseline(rows);
if (series.length === 0) {
return <p className="local-dashboard-empty" role="status">{NO_SAVINGS_ROWS}</p>;
}
return (
<>
{series.map(([baseline, days]) => (
<div className="local-dashboard-table-wrap" key={baseline}>
<table aria-label={`Savings per day vs ${baseline}`}>
<thead><tr><th>Day</th><th>{`Savings vs ${baseline}`}</th><th>Runs</th></tr></thead>
<tbody>{days.map((row, index) => (
<tr key={`${String(row.window_start)}-${index}`}>
<td>{isMissing(row.window_start) ? 'Not recorded' : String(row.window_start)}</td>
<td>{savings(row)}</td>
<td>{isMissing(row.runs_total) ? 'Not recorded' : String(row.runs_total)}</td>
</tr>
))}</tbody>
</table>
</div>
))}
<p className="local-dashboard-caption">{SAVINGS_MODELLED}</p>
</>
);
}

/** The palette widget: the same series over the served exposure. */
export default function SavingsSeriesWidget() {
const query = useProjectionSnapshotQuery<MeteringSummaryRow>({
queryKey: ['savings-series-widget', TOPICS.meteringSummary],
topic: TOPICS.meteringSummary,
refetchInterval: 30_000,
});
const rows = query.data?.rows ?? [];
return (
<ComponentWrapper
title="Savings per day"
isLoading={query.isLoading}
error={query.error}
isEmpty={rows.length === 0}
emptyMessage="No savings rows yet"
emptyHint="Rows appear once node_projection_metering_summary serves its day rows."
>
<MeteringDaySeries rows={rows} />
</ComponentWrapper>
);
}
21 changes: 21 additions & 0 deletions src/components/dashboard/usage/UsageByModelDayWidget.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { UsageByModelDayTable } from './UsageByModelDayWidget';

const meta: Meta<typeof UsageByModelDayTable> = {
title: 'Dashboard / UsageByModelDay',
component: UsageByModelDayTable,
parameters: { layout: 'padded' },
};
export default meta;
type Story = StoryObj<typeof UsageByModelDayTable>;

export const Empty: Story = { args: { rows: [] } };
export const Populated: Story = {
args: {
rows: [
{ tenant_id: 'tenant-a', usage_day: '2026-10-02', model_id: 'Qwen3.8-27B', input_tokens: 1620, output_tokens: 520, cost_usd: 0.25, measured_cost_usd: 0.25, unmeasured_call_count: 0, call_count: 10 },
{ tenant_id: 'tenant-a', usage_day: '2026-10-02', model_id: 'glm-4.6', input_tokens: 300, output_tokens: 30, cost_usd: 9.25, measured_cost_usd: 0.25, unmeasured_call_count: 1, call_count: 2 },
{ tenant_id: 'tenant-a', usage_day: '2026-10-01', model_id: 'glm-4.6', input_tokens: 90, output_tokens: 9, cost_usd: 4, measured_cost_usd: null, unmeasured_call_count: 1, call_count: 1 },
],
},
};
Loading
Loading