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
2 changes: 1 addition & 1 deletion apps/web/src/lab/cost-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const LAB_NOW_MS = LAB_TODAY_MS + 14.5 * 3_600_000
const ESTIMATED_DAYS = 2
const DAY_MS = 86_400_000

type Line = Readonly<Record<CostGroupBy, string> & { daily: number; drift: number }>
type Line = Readonly<Record<CostGroupBy | "usage_type", string> & { daily: number; drift: number }>

const line = (
cloud_service: string,
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/lab/cost-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import { defineMessageUnion } from "foldkit/message"
import { fromString } from "foldkit/url"
import {
BreakdownInput,
FacetsInput,
FetchCostBreakdown,
FetchCostFacets,
FetchCostSources,
FetchCostTimeseries,
FetchCostTotals,
Expand All @@ -26,6 +28,7 @@ import * as Cost from "../page/cost/update"
import { view as costView } from "../page/cost/view"
import type { Resources } from "../platform/resources"
import { urlToAppRoute } from "../route"
import { perDimension } from "../page/cost/derive"
import {
LAB_NOW_MS,
LAB_OPENROUTER_KEY,
Expand Down Expand Up @@ -166,6 +169,7 @@ const keyArgs = Schema.decodeUnknownOption(Schema.Struct({ key: Schema.String })
const windowArgs = argsOf(WindowInput)
const timeseriesArgs = argsOf(TimeseriesInput)
const breakdownArgs = argsOf(BreakdownInput)
const facetsArgs = argsOf(FacetsInput)

/** A read's answer: the fixture's, or the outage in the `error` state. */
const settled = <A>(state: LabState, value: () => A): Result.Result<A, typeof failure> =>
Expand Down Expand Up @@ -236,6 +240,24 @@ const answerOf = (world: World, search: Search, command: CostCommand): Option.Op
),
}),
)
case FetchCostFacets.name:
return Option.map(facetsArgs(command.args), ({ key, input }) =>
CostMessage.CompletedLoadFacets({
key,
result: settled(state, () =>
perDimension(
(groupBy) =>
labBreakdown({
days,
filters: filtersOf(input.filters[groupBy]),
withoutOpenRouter,
groupBy,
sortBy: "cost",
}).rows,
),
),
}),
)
case ReadCostGrants.name:
return Option.some(
CostMessage.CompletedReadGrants({
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/page/cost/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { readGrants } from "../../data/session-tenant"
import { MapleInternalApiClient } from "../../platform/api-clients"
import { COST_GROUP_BYS } from "../../route/search"
import { resolveWindowCommand } from "../kit/time-frame"
import { perDimension } from "./derive"
import { Message } from "./message"

// Each read answers with the key it was sent under; its input is what the key was built from.
Expand Down Expand Up @@ -86,6 +87,42 @@ export const FetchCostBreakdown = Command.define("FetchCostBreakdown", {
}).pipe(settleTo((result) => Message.CompletedLoadBreakdown({ key, result }))),
})

/** Per dimension, the filters its own facet reads under: every other dimension's, never its own. */
export const FacetsInput = Schema.Struct({
startTime: Schema.String,
endTime: Schema.String,
filters: Schema.Struct(perDimension(() => Schema.optional(CostFilters))),
limit: Schema.Number,
})
export type FacetsInput = typeof FacetsInput.Type

/** The filter rail's values: one breakdown per dimension, read side by side. */
export const FetchCostFacets = Command.define("FetchCostFacets", {
args: { key: Schema.String, input: FacetsInput },
messages: [Message.CompletedLoadFacets],
execute: ({ key, input }) =>
Effect.all(
perDimension((groupBy) =>
Effect.map(
getCostBreakdown({
data: {
...windowOf({
startTime: input.startTime,
endTime: input.endTime,
filters: input.filters[groupBy],
}),
groupBy,
sortBy: "cost",
limit: input.limit,
},
}),
(response) => response.rows,
),
),
{ concurrency: "unbounded" },
).pipe(settleTo((result) => Message.CompletedLoadFacets({ key, result }))),
})

export const ReadCostGrants = Command.define("ReadCostGrants", {
messages: [Message.CompletedReadGrants],
execute: Effect.map(readGrants, (grants) => Message.CompletedReadGrants({ grants })),
Expand Down
29 changes: 20 additions & 9 deletions apps/web/src/page/cost/cost.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,20 +623,31 @@ describe("cost page", () => {
)
})

test("a row links to the view filtered to it; the chip removes the filter again", () => {
test("a row breaks its key down by the next dimension; the chip removes the filter again", () => {
const model = populated()
scene(
{ update: pageUpdate, view },
given(model),
resolveTheme,
expectView(role("link", { name: "north-1" })).toHaveAttr(
"href",
"/cost?groupBy=region&region=%5B%22north-1%22%5D",
"/cost?groupBy=cloud_service&region=%5B%22north-1%22%5D",
),
// The empty key filters too: its placeholder is only how it reads.
expectView(role("link", { name: "No region" })).toHaveAttr(
"href",
"/cost?groupBy=region&region=%5B%22%22%5D",
"/cost?groupBy=cloud_service&region=%5B%22%22%5D",
),
)
// Grouped by service under a region filter, a service row moves on to accounts,
// not back to regions.
scene(
{ update: pageUpdate, view },
given(answered(routeChanged(model, { search: { region: ["north-1"] } }, context).model)),
resolveTheme,
expectView(role("link", { name: "Unassigned" })).toHaveAttr(
"href",
"/cost?groupBy=account&cloud_service=%5B%22%22%5D&region=%5B%22north-1%22%5D",
),
)
const filtered = routeChanged(model, { search: { groupBy: "region", region: ["north-1"] } }, context)
Expand Down Expand Up @@ -670,18 +681,18 @@ describe("cost page", () => {
{ update: pageUpdate, view },
given(model),
resolveTheme,
click(role("button", { name: "Usage type" })),
click(role("button", { name: "Region" })),
expectOutMessage(
PageOutMessage.RequestedNavigation({ url: "/cost?groupBy=usage_type", isReplace: false }),
PageOutMessage.RequestedNavigation({ url: "/cost?groupBy=region", isReplace: false }),
),
)
const regrouped = routeChanged(model, { search: { groupBy: "usage_type" } }, context)
const regrouped = routeChanged(model, { search: { groupBy: "region" } }, context)
expect(names(regrouped.commands)).not.toContain("FetchCostTotals")
expect(inputOf(regrouped.commands, "FetchCostTimeseries")).toMatchObject({
groupBy: "usage_type",
groupBy: "region",
limit: 8,
})
expect(inputOf(regrouped.commands, "FetchCostBreakdown")).toMatchObject({ groupBy: "usage_type" })
expect(inputOf(regrouped.commands, "FetchCostBreakdown")).toMatchObject({ groupBy: "region" })
})

test("sorting by change moves the URL, and the breakdown alone is read again in that order", () => {
Expand Down Expand Up @@ -886,7 +897,7 @@ describe("cost page", () => {
estimatedFrom: "2026-11-09",
currency: "USD",
})
expect(chartRows(response, "usage_type")).toEqual([
expect(chartRows(response, "region")).toEqual([
{ bucket: "2026-11-09T00:00:00Z", "bucket (series)": 5, "partial (series)": 4, partial: true },
])
})
Expand Down
68 changes: 61 additions & 7 deletions apps/web/src/page/cost/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,22 @@ import { OTHER_LABEL } from "@maple/views/charts/series/bucket-series"
import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect"
import { COST_GROUP_BYS } from "../../route/search"
import type { Search } from "./model"
import { accountLabel } from "./marks"
import { COST_PROVIDERS } from "./providers"

// What the cost page reads off its URL and its answers: the grouping, the filters, money and days.

export type CostGroupBy = (typeof COST_GROUP_BYS)[number]

/** One value per dimension, keyed by it: the facets' reads, filters and answers. */
export const perDimension = <A>(of: (dimension: CostGroupBy) => A): Readonly<Record<CostGroupBy, A>> => ({
cloud_service: of("cloud_service"),
provider: of("provider"),
service_category: of("service_category"),
region: of("region"),
account: of("account"),
})

export const DEFAULT_GROUP_BY: CostGroupBy = "cloud_service"

/** Each dimension's name in the control, the chips and the table, and what an empty key reads as. */
Expand All @@ -27,7 +37,6 @@ export const DIMENSIONS = {
provider: { label: "Provider", noun: "provider", empty: "No provider" },
service_category: { label: "Category", noun: "category", empty: "Uncategorized" },
region: { label: "Region", noun: "region", empty: "No region" },
usage_type: { label: "Usage type", noun: "usage type", empty: "No usage type" },
// The account the connection reads, which for a management account bills its members' cost too.
account: { label: "Connected account", noun: "connected account", empty: "No account" },
} as const satisfies Record<CostGroupBy, Readonly<{ label: string; noun: string; empty: string }>>
Expand All @@ -39,11 +48,16 @@ export const sortOf = (search: Search): CostBreakdownSortBy => search.sort ?? "c
const decodeProvider = Schema.decodeUnknownOption(CostProvider)

/**
* A key as the page shows it: the provider's own text, the dimension's placeholder for "", and a
* provider by its name, not by the id rows carry.
* A key as the page shows it: the provider's own text, the dimension's placeholder for "", a
* provider by its name, not by the id rows carry, and an account by the provider that reads it.
*/
export const keyLabel = (dimension: CostGroupBy, key: string): string => {
export const keyLabel = (
dimension: CostGroupBy,
key: string,
sources: ReadonlyArray<CostSourceSummary> = [],
): string => {
if (key === "") return DIMENSIONS[dimension].empty
if (dimension === "account") return accountLabel(key, sources)
if (dimension !== "provider") return key
return Option.match(decodeProvider(key), {
onNone: () => key,
Expand Down Expand Up @@ -86,6 +100,44 @@ export const withFilter = (search: Search, filter: ActiveFilter): Search => {
: withValues(search, filter.dimension, Arr.append(values, filter.value))
}

/**
* Where a row's click goes next: the grouping that splits it further, skipping one already
* narrowed. Grouped by service it moves to region, by anything else to service.
*/
const DRILL_ORDER = {
cloud_service: ["region", "account"],
provider: ["cloud_service", "account", "region"],
service_category: ["cloud_service", "region"],
region: ["cloud_service", "account"],
account: ["cloud_service", "region"],
} as const satisfies Record<CostGroupBy, ReadonlyArray<CostGroupBy>>

/** The grouping a row's click switches to, or the current one when every next step is narrowed. */
export const drillTarget = (search: Search, dimension: CostGroupBy): CostGroupBy =>
Arr.findFirst(DRILL_ORDER[dimension], (next) => (search[next] ?? []).length === 0).pipe(
Option.getOrElse(() => dimension),
)

/** A row's click: the page narrowed to the row's key, then split by the next dimension. */
export const drilledInto = (search: Search, filter: ActiveFilter): Search => {
const narrowed = withFilter(search, filter)
return narrowed === search ? search : { ...narrowed, groupBy: drillTarget(narrowed, filter.dimension) }
}

/** A rail checkbox: the value leaves the dimension's filter when it is in it, else joins it. */
export const toggledFilter = (search: Search, filter: ActiveFilter): Search =>
hasFilter(search, filter) ? withoutFilter(search, filter) : withFilter(search, filter)

/** A rail row's Only: the dimension narrowed to the one value. */
export const onlyFilter = (search: Search, filter: ActiveFilter): Search =>
filter.value.length > COST_FILTER_MAX_VALUE_LENGTH
? search
: withValues(search, filter.dimension, [filter.value])

/** What a dimension's facet reads under: every filter but its own, so its other values stay on offer. */
export const facetFilters = (search: Search, dimension: CostGroupBy): CostFilters | undefined =>
requestFilters(withValues(search, dimension, []))

export const withoutFilter = (search: Search, filter: ActiveFilter): Search =>
withValues(
search,
Expand Down Expand Up @@ -211,9 +263,10 @@ const ROW_FIELDS: ReadonlyArray<string> = ["bucket", "partial"]
export const seriesLabels = (
dimension: CostGroupBy,
series: ReadonlyArray<SeriesKey>,
sources: ReadonlyArray<CostSourceSummary> = [],
): ReadonlyArray<string> => {
// A provider's series reads by its name, as its table row does.
const nameOf = (key: string) => keyLabel(dimension, key)
// A series reads by the name its table row shows.
const nameOf = (key: string) => keyLabel(dimension, key, sources)
const named = Arr.flatMap(series, (entry) => (entry.other || entry.key === "" ? [] : [nameOf(entry.key)]))
const taken = [...named, ...ROW_FIELDS]
const empty = freeName(DIMENSIONS[dimension].empty, "empty", taken)
Expand All @@ -236,8 +289,9 @@ export const seriesLabels = (
export const chartRows = (
response: CostTimeseriesResponse,
dimension: CostGroupBy,
sources: ReadonlyArray<CostSourceSummary> = [],
): ReadonlyArray<ChartRow> => {
const labels = seriesLabels(dimension, response.series)
const labels = seriesLabels(dimension, response.series, sources)
return Arr.map(response.days, (day, index) => ({
bucket: `${day}T00:00:00Z`,
...Rec.fromEntries(
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/page/cost/marks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { CostSourceSummary } from "@maple/domain/http"
import { Schema } from "effect"
import { describe, expect, test } from "vitest"
import { facetFilters, keyLabel, onlyFilter, toggledFilter } from "./derive"
import { keyDetail, keyMark } from "./marks"

const source = (
provider: CostSourceSummary["provider"],
accountId: string,
id = "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e1f",
): CostSourceSummary =>
Schema.decodeSync(CostSourceSummary)({
id,
provider,
accountId,
status: "ok",
watermarkDate: "2026-11-10",
backfillDate: "2026-01-01",
historyComplete: true,
lastSyncedAt: null,
lastError: null,
lastErrorReason: null,
requestsThisMonth: 0,
})

const sources = [source("aws", "465760687006"), source("openrouter", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6")]

describe("cost key marks", () => {
test("an account reads as the provider that bills it, an OpenRouter one by name with its id aside", () => {
expect(keyLabel("account", "465760687006", sources)).toBe("AWS 465760687006")
expect(keyLabel("account", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6", sources)).toBe("OpenRouter")
expect(keyDetail("account", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6", sources)).toBe(
"user_2zZ0ZRlIh83bwUonpAHz1xTcuG6",
)
expect(keyMark("account", "465760687006", sources)?.icon).toBe(keyMark("provider", "aws", [])?.icon)
expect(keyMark("account", "user_2zZ0ZRlIh83bwUonpAHz1xTcuG6", sources)?.icon).toBe(
keyMark("provider", "openrouter", [])?.icon,
)
})

test("two OpenRouter accounts keep their ids so their series stay apart", () => {
const two = [...sources, source("openrouter", "org_second", "0195f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e20")]
expect(keyLabel("account", "org_second", two)).toBe("OpenRouter org_second")
})

test("an AWS service wears the AWS mark, an OpenRouter model its vendor's", () => {
const aws = keyMark("provider", "aws", [])?.icon
expect(keyMark("cloud_service", "Amazon Elastic Compute Cloud - Compute", [])?.icon).toBe(aws)
expect(keyMark("region", "eu-west-1", [])?.icon).toBe(aws)
expect(keyMark("cloud_service", "openai/gpt-5", [])?.icon).not.toBe(aws)
expect(keyMark("cloud_service", "", [])).toBeNull()
})

test("an AWS region says where it is; an unknown one adds nothing", () => {
expect(keyDetail("region", "eu-central-1", [])).toBe("Frankfurt")
expect(keyDetail("region", "mars-north-1", [])).toBeNull()
})
})

describe("cost filter rail", () => {
test("a facet reads under every filter but its own, so its other values stay on offer", () => {
const search = { provider: ["aws"], region: ["eu-west-1"] }
expect(facetFilters(search, "provider")).toEqual({ region: ["eu-west-1"] })
expect(facetFilters(search, "cloud_service")).toEqual({ provider: ["aws"], region: ["eu-west-1"] })
})

test("a checkbox adds the value or takes it out; Only leaves it alone in its dimension", () => {
const search = { provider: ["aws"] }
expect(toggledFilter(search, { dimension: "provider", value: "openrouter" }).provider).toEqual([
"aws",
"openrouter",
])
expect(toggledFilter(search, { dimension: "provider", value: "aws" }).provider).toBeUndefined()
expect(
onlyFilter({ provider: ["aws", "openrouter"] }, { dimension: "provider", value: "openrouter" })
.provider,
).toEqual(["openrouter"])
})
})
Loading
Loading