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
10 changes: 9 additions & 1 deletion backend/src/services/usage_workspace_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct WorkspacePanel {
pub height: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table_display: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -133,8 +135,14 @@ pub fn validate(config: &WorkspaceConfig) -> AppResult<()> {
.height
.as_deref()
.is_some_and(|height| !["compact", "standard", "tall"].contains(&height))
|| panel
.table_display
.as_deref()
.is_some_and(|display| !["always", "accordion"].contains(&display))
{
return Err(invalid("Choose 1 to 3 columns and a valid panel height"));
return Err(invalid(
"Choose a valid panel layout and data table display",
));
}
AnalyticsQuery {
period: (filters.period != "custom").then(|| filters.period.clone()),
Expand Down
11 changes: 9 additions & 2 deletions backend/src/services/usage_workspace_service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ fn workspace_rejects_invalid_queries_and_duplicate_panels() {
fn workspace_preserves_legacy_width_and_validates_panel_layout_and_interval() {
let legacy = config();
assert!(legacy.draft.panels[0].span.is_none());
assert!(legacy.draft.panels[0].table_display.is_none());
assert!(legacy.draft.panels[0].wide);
assert!(validate(&legacy).is_ok());
for span in [1, 2, 3] {
Expand All @@ -37,20 +38,26 @@ fn workspace_preserves_legacy_width_and_validates_panel_layout_and_interval() {
panel.span = Some(span);
panel.height = Some("tall".into());
panel.interval = Some("week".into());
panel.table_display = Some("accordion".into());
panel.measure = "prompt_tokens".into();
assert!(validate(&value).is_ok());
let restored: WorkspaceConfig =
serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap();
assert_eq!(restored.draft.panels[0].span, Some(span));
assert_eq!(restored.draft.panels[0].interval.as_deref(), Some("week"));
assert_eq!(
restored.draft.panels[0].table_display.as_deref(),
Some("accordion")
);
}
for field in ["span", "height", "interval"] {
for field in ["span", "height", "interval", "table_display"] {
let mut value = config();
let panel = &mut value.draft.panels[0];
match field {
"span" => panel.span = Some(4),
"height" => panel.height = Some("unbounded".into()),
_ => panel.interval = Some("minute".into()),
"interval" => panel.interval = Some("minute".into()),
_ => panel.table_display = Some("missing".into()),
}
assert!(validate(&value).is_err());
}
Expand Down
27 changes: 24 additions & 3 deletions frontend/e2e/billing-analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ test("all three analytics approaches are interactive and saved views survive rel
.click();
await select(page, "Show", "Top 10 + Other");
await select(page, "Chart type", "Bar");
await select(page, "Data table", "Accordion");
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(
Expand All @@ -29,6 +30,26 @@ test("all three analytics approaches are interactive and saved views survive rel
exact: true,
}),
).toBeVisible();
const serviceMix = page.getByRole("article", { name: "Service mix" });
await expect(
serviceMix.getByRole("table", { name: "Service mix data" }),
).toBeHidden();
await serviceMix.getByText("Data table", { exact: true }).click();
await expect(
serviceMix.getByRole("table", { name: "Service mix data" }),
).toBeVisible();
await serviceMix.getByText("Data table", { exact: true }).click();
await expect(
serviceMix.getByRole("table", { name: "Service mix data" }),
).toBeHidden();
await page
.getByRole("button", { name: "Configure Service mix", exact: true })
.click();
await select(page, "Data table", "Always open");
await page.keyboard.press("Escape");
await expect(
serviceMix.getByRole("table", { name: "Service mix data" }),
).toBeVisible();
await page.getByRole("button", { name: /Overview GA inspired/ }).click();
await expect(
page.getByRole("img", { name: /Service mix: bar chart/ }),
Expand Down Expand Up @@ -103,8 +124,9 @@ test("all three analytics approaches are interactive and saved views survive rel
await expect(
page.getByRole("img", { name: /Explore service usage: pie/ }),
).toBeVisible();
await page.getByText("View data table", { exact: true }).click();
await expect(page.getByRole("table")).toBeVisible();
await expect(
page.getByRole("table", { name: "Explore service usage data" }),
).toBeVisible();
await page
.getByRole("button", { name: "Use in Operations", exact: true })
.click();
Expand Down Expand Up @@ -979,7 +1001,6 @@ for (const chart of ["bar", "line", "combo", "pie"] as const) {
await expect(
panel.locator('[title="Service 0: 9,007,199,254.740993 credits"]'),
).toBeVisible();
await panel.getByText("View data table", { exact: true }).click();
await expect(panel.getByRole("table")).toContainText(
"9,007,199,254.740993",
);
Expand Down
40 changes: 31 additions & 9 deletions frontend/src/components/billing-analytics/analytics-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ import { cn } from "@/lib/utils";
import { ChartView } from "./chart-view";
import { AnalyticsSelect, PanelControls } from "./controls";
import { SortablePanel } from "./sortable-panel";
import { TokenMetricCaption, TokenMetricPicker } from "./token-metric-picker";
import { sumTokenMetrics, type TokenMetric } from "./token-metrics";
import "./operations.css";

export type AnalyticsSample = (
Expand Down Expand Up @@ -162,6 +164,7 @@ function Summary({
view: AnalyticsView;
sample?: AnalyticsSample;
}) {
const [tokenTypes, setTokenTypes] = useState<TokenMetric[]>(["total_tokens"]);
const query = useUsageAnalytics(
view.filters,
{
Expand Down Expand Up @@ -204,10 +207,14 @@ function Summary({
note: "One count per metered request",
},
{
label: "Total tokens",
value: formatAnalyticsValue(data.totals.total_tokens, "tokens", true),
label: "Tokens",
value: formatAnalyticsValue(
sumTokenMetrics(data.totals, tokenTypes),
"tokens",
true,
),
suffix: "tokens",
note: "Input + output tokens",
tokenControl: true,
},
{
label: "Active users",
Expand All @@ -225,9 +232,18 @@ function Summary({
>
{stats.map((stat) => (
<Card key={stat.label} className="min-w-0 px-4 py-4">
<p className="text-[10px] font-medium uppercase tracking-[1.5px] text-muted-foreground">
{stat.label}
</p>
{stat.tokenControl ? (
<TokenMetricPicker
label="Summary token types"
selected={tokenTypes}
onChange={setTokenTypes}
compact
/>
) : (
<p className="text-[10px] font-medium uppercase tracking-[1.5px] text-muted-foreground">
{stat.label}
</p>
)}
<div className="mt-3 flex flex-wrap items-baseline gap-x-2">
<span className="font-display text-[28px] font-medium leading-none tracking-tight tabular-nums">
{stat.value}
Expand All @@ -236,9 +252,15 @@ function Summary({
{stat.suffix}
</span>
</div>
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{stat.note}
</p>
{stat.tokenControl ? (
<TokenMetricCaption selected={tokenTypes} />
) : (
stat.note && (
<p className="mt-2 text-[10px] leading-relaxed text-muted-foreground">
{stat.note}
</p>
)
)}
</Card>
))}
</div>
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/billing-analytics/chart-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { newPanel } from "@/lib/usage-analytics";
import type { AnalyticsResult } from "@/schemas/usage-analytics";
import { usageStats } from "@/test/admin-usage-fixture";
import { ChartView } from "./chart-view";

const data: AnalyticsResult = {
window: {
from: "2026-09-18T00:00:00Z",
to: "2026-09-19T00:00:00Z",
period: "24h",
},
freshness: {
rolled_up_through: "2026-09-19T00:00:00Z",
tail_rows: 0,
validated: true,
},
granularity: "hour",
unit: "requests",
total: 7,
totals: usageStats(),
points: [],
series: [],
slices: [
{
id: "service-1",
label: "Example model",
value: 7,
unknown_cost_events: 0,
is_other: false,
},
],
};

it("shows the plotted data table by default", () => {
render(
<ChartView
data={data}
panel={newPanel({
title: "Request traffic",
chart: "bar",
measure: "requests",
})}
/>,
);
const table = screen.getByRole("table", { name: "Request traffic data" });
expect(table).toBeVisible();
expect(within(table).getByText("Example model")).toBeVisible();
expect(screen.queryByText("View data table")).not.toBeInTheDocument();
});

it("opens and closes the data table in accordion mode", async () => {
render(
<ChartView
data={data}
panel={newPanel({
title: "Request traffic",
chart: "bar",
measure: "requests",
table_display: "accordion",
})}
/>,
);
const table = screen.getByRole("table", {
name: "Request traffic data",
hidden: true,
});
const toggle = screen.getByText("Data table");
expect(table).not.toBeVisible();
await userEvent.click(toggle);
expect(table).toBeVisible();
await userEvent.click(toggle);
expect(table).not.toBeVisible();
});
Loading
Loading