From 0b4b4eb79ca4d861a2740224d595b78838c2dc5f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 00:46:01 +0100 Subject: [PATCH] feat(cost): filter rail, provider marks and drill-down on the Cost page The Cost page gets the filter rail the other list pages use: one checkbox section per dimension (provider, connected account, service, category, region), each value with its mark and its cost, searchable where long, with Only and Clear all. Each section reads under every filter but its own, so its other values stay on offer. Every key carries a mark in the table, the chart legend and the filter chips: AWS services and regions the AWS logo, OpenRouter models their vendor's, categories a glyph. A connected account reads as the provider that bills it ("AWS 4657...", "OpenRouter") instead of a bare id, and an AWS region names its place. A row click now filters and regroups to the next dimension (service to region, provider/account/category/region to service), skipping one already narrowed. Usage type leaves the grouping control and the URL. --- apps/web/src/lab/cost-fixture.ts | 2 +- apps/web/src/lab/cost-lab.ts | 22 +++ apps/web/src/page/cost/command.ts | 37 +++++ apps/web/src/page/cost/cost.scene.test.ts | 29 ++-- apps/web/src/page/cost/derive.ts | 68 +++++++- apps/web/src/page/cost/marks.test.ts | 79 +++++++++ apps/web/src/page/cost/marks.ts | 150 ++++++++++++++++++ apps/web/src/page/cost/message.ts | 14 ++ apps/web/src/page/cost/model.ts | 13 +- apps/web/src/page/cost/update.ts | 41 +++++ apps/web/src/page/cost/view-chart.ts | 48 +++++- apps/web/src/page/cost/view-filters.ts | 103 ++++++++++++ apps/web/src/page/cost/view-table.ts | 41 +++-- apps/web/src/page/cost/view.ts | 14 +- apps/web/src/route/search.ts | 10 +- packages/views/src/charts/bar-chart.ts | 5 +- .../views/src/charts/timeseries-legend.ts | 21 ++- packages/views/src/lib/ai-vendor.ts | 13 +- .../src/ui/filters/active-filter-chips.ts | 12 +- .../views/src/ui/filters/filter-section.ts | 15 +- 20 files changed, 680 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/page/cost/marks.test.ts create mode 100644 apps/web/src/page/cost/marks.ts create mode 100644 apps/web/src/page/cost/view-filters.ts diff --git a/apps/web/src/lab/cost-fixture.ts b/apps/web/src/lab/cost-fixture.ts index fcf71aed1f..4d520ac28b 100644 --- a/apps/web/src/lab/cost-fixture.ts +++ b/apps/web/src/lab/cost-fixture.ts @@ -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 & { daily: number; drift: number }> +type Line = Readonly & { daily: number; drift: number }> const line = ( cloud_service: string, diff --git a/apps/web/src/lab/cost-lab.ts b/apps/web/src/lab/cost-lab.ts index 8023445b12..0838405634 100644 --- a/apps/web/src/lab/cost-lab.ts +++ b/apps/web/src/lab/cost-lab.ts @@ -9,7 +9,9 @@ import { defineMessageUnion } from "foldkit/message" import { fromString } from "foldkit/url" import { BreakdownInput, + FacetsInput, FetchCostBreakdown, + FetchCostFacets, FetchCostSources, FetchCostTimeseries, FetchCostTotals, @@ -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, @@ -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 = (state: LabState, value: () => A): Result.Result => @@ -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({ diff --git a/apps/web/src/page/cost/command.ts b/apps/web/src/page/cost/command.ts index f3c9c32d96..d2bc34c558 100644 --- a/apps/web/src/page/cost/command.ts +++ b/apps/web/src/page/cost/command.ts @@ -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. @@ -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 })), diff --git a/apps/web/src/page/cost/cost.scene.test.ts b/apps/web/src/page/cost/cost.scene.test.ts index 61c9640f8a..84eb78769d 100644 --- a/apps/web/src/page/cost/cost.scene.test.ts +++ b/apps/web/src/page/cost/cost.scene.test.ts @@ -623,7 +623,7 @@ 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 }, @@ -631,12 +631,23 @@ describe("cost page", () => { resolveTheme, expectView(role("link", { name: "north-1" })).toHaveAttr( "href", - "/cost?groupBy=region®ion=%5B%22north-1%22%5D", + "/cost?groupBy=cloud_service®ion=%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®ion=%5B%22%22%5D", + "/cost?groupBy=cloud_service®ion=%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®ion=%5B%22north-1%22%5D", ), ) const filtered = routeChanged(model, { search: { groupBy: "region", region: ["north-1"] } }, context) @@ -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", () => { @@ -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 }, ]) }) diff --git a/apps/web/src/page/cost/derive.ts b/apps/web/src/page/cost/derive.ts index 7a4c82c6e5..6079ead891 100644 --- a/apps/web/src/page/cost/derive.ts +++ b/apps/web/src/page/cost/derive.ts @@ -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 = (of: (dimension: CostGroupBy) => A): Readonly> => ({ + 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. */ @@ -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> @@ -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 = [], +): 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, @@ -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> + +/** 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, @@ -211,9 +263,10 @@ const ROW_FIELDS: ReadonlyArray = ["bucket", "partial"] export const seriesLabels = ( dimension: CostGroupBy, series: ReadonlyArray, + sources: ReadonlyArray = [], ): ReadonlyArray => { - // 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) @@ -236,8 +289,9 @@ export const seriesLabels = ( export const chartRows = ( response: CostTimeseriesResponse, dimension: CostGroupBy, + sources: ReadonlyArray = [], ): ReadonlyArray => { - 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( diff --git a/apps/web/src/page/cost/marks.test.ts b/apps/web/src/page/cost/marks.test.ts new file mode 100644 index 0000000000..70f688758c --- /dev/null +++ b/apps/web/src/page/cost/marks.test.ts @@ -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"]) + }) +}) diff --git a/apps/web/src/page/cost/marks.ts b/apps/web/src/page/cost/marks.ts new file mode 100644 index 0000000000..e65034f63e --- /dev/null +++ b/apps/web/src/page/cost/marks.ts @@ -0,0 +1,150 @@ +import { CostProvider } from "@maple/domain/cost" +import type { CostSourceSummary } from "@maple/domain/http" +import type { SeriesMark } from "@maple/views/charts/timeseries-legend" +import { modelSlugIcon } from "@maple/views/lib/ai-vendor" +import type { IconName } from "@maple/views/ui/icon" +import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" +import type { CostGroupBy } from "./derive" +import { COST_PROVIDERS } from "./providers" + +// What a key of the grouping is at a glance: the logo of whatever bills it, or its kind's glyph. + +export type KeyMark = SeriesMark + +const decodeProvider = Schema.decodeUnknownOption(CostProvider) + +const providerMark = (provider: CostProvider): KeyMark => { + const entry = COST_PROVIDERS[provider] + return entry.iconClassName === undefined + ? { icon: entry.icon } + : { icon: entry.icon, className: entry.iconClassName } +} + +/** OpenRouter names a model `vendor/model`; an AWS service name never has a slash. */ +const isModelSlug = (key: string): boolean => Str.includes("/")(key) + +/** An AWS account id is twelve digits; an OpenRouter one is its user or org id. */ +const isAwsAccountId = (key: string): boolean => /^\d{12}$/u.test(key) + +/** The provider whose source reads the account, from the org's sources, then by the id's shape. */ +export const accountProvider = ( + key: string, + sources: ReadonlyArray, +): CostProvider | undefined => + Option.getOrUndefined( + Option.orElse( + Option.map( + Arr.findFirst(sources, (source) => source.accountId === key), + (source) => source.provider, + ), + () => (isAwsAccountId(key) ? Option.some("aws") : Option.none()), + ), + ) + +const CATEGORY_ICONS = { + "AI and Machine Learning": "SparkleIcon", + Analytics: "ChartBarIcon", + Databases: "DatabaseIcon", + Storage: "BoxArchiveIcon", + Networking: "NetworkNodesIcon", + Security: "ShieldIcon", + "Management and Governance": "GaugeIcon", + "Developer Tools": "CodeIcon", + Compute: "MicrochipIcon", +} as const satisfies Record + +const isCategory = (key: string): key is keyof typeof CATEGORY_ICONS => + Rec.has(CATEGORY_ICONS, key) + +const glyph = (name: IconName): KeyMark => ({ icon: name, className: "text-muted-foreground" }) + +/** The mark beside a key in the table, the legend and the filter chips; null for an empty key. */ +export const keyMark = ( + dimension: CostGroupBy, + key: string, + sources: ReadonlyArray, +): KeyMark | null => { + if (key === "") return null + switch (dimension) { + case "provider": + return Option.match(decodeProvider(key), { onNone: () => null, onSome: providerMark }) + case "cloud_service": + if (isModelSlug(key)) return { icon: modelSlugIcon(key) } + return key === "AWS Lambda" ? { icon: "AwsLambdaIcon" } : providerMark("aws") + case "service_category": + return glyph(isCategory(key) ? CATEGORY_ICONS[key] : "CubeIcon") + case "region": + return key === "global" ? glyph("GlobeIcon") : providerMark("aws") + case "account": { + const provider = accountProvider(key, sources) + return provider === undefined ? glyph("IdBadgeIcon") : providerMark(provider) + } + } +} + +/** + * An account by what reads it: an AWS one by its id, an OpenRouter one by name alone while it is + * the org's only one (its id means nothing to a reader). Unknown ids read as they are. + */ +export const accountLabel = (key: string, sources: ReadonlyArray): string => { + const provider = accountProvider(key, sources) + if (provider === undefined) return key + const name = COST_PROVIDERS[provider].name + if (provider === "aws") return `${name} ${key}` + const sameProvider = Arr.filter(sources, (source) => source.provider === provider) + return sameProvider.length <= 1 ? name : `${name} ${key}` +} + +/** AWS region codes and where they are, as the AWS console names them. */ +const AWS_REGIONS = { + "us-east-1": "N. Virginia", + "us-east-2": "Ohio", + "us-west-1": "N. California", + "us-west-2": "Oregon", + "af-south-1": "Cape Town", + "ap-east-1": "Hong Kong", + "ap-south-1": "Mumbai", + "ap-south-2": "Hyderabad", + "ap-southeast-1": "Singapore", + "ap-southeast-2": "Sydney", + "ap-southeast-3": "Jakarta", + "ap-southeast-4": "Melbourne", + "ap-northeast-1": "Tokyo", + "ap-northeast-2": "Seoul", + "ap-northeast-3": "Osaka", + "ca-central-1": "Canada Central", + "ca-west-1": "Calgary", + "eu-central-1": "Frankfurt", + "eu-central-2": "Zurich", + "eu-west-1": "Ireland", + "eu-west-2": "London", + "eu-west-3": "Paris", + "eu-south-1": "Milan", + "eu-south-2": "Spain", + "eu-north-1": "Stockholm", + "il-central-1": "Tel Aviv", + "me-south-1": "Bahrain", + "me-central-1": "UAE", + "sa-east-1": "São Paulo", + "us-gov-east-1": "GovCloud US-East", + "us-gov-west-1": "GovCloud US-West", +} as const satisfies Record + +const isAwsRegion = (key: string): key is keyof typeof AWS_REGIONS => + Rec.has(AWS_REGIONS, key) + +/** + * What the name leaves out: where an AWS region is, the id behind an OpenRouter account's name. + * Null when the name says it all. + */ +export const keyDetail = ( + dimension: CostGroupBy, + key: string, + sources: ReadonlyArray, +): string | null => { + if (dimension === "region") return isAwsRegion(key) ? AWS_REGIONS[key] : null + if (dimension === "account") { + return accountProvider(key, sources) === "openrouter" ? key : null + } + return null +} diff --git a/apps/web/src/page/cost/message.ts b/apps/web/src/page/cost/message.ts index ce3cb4292a..3ebb95de73 100644 --- a/apps/web/src/page/cost/message.ts +++ b/apps/web/src/page/cost/message.ts @@ -1,5 +1,6 @@ import { CostBreakdownResponse, + CostBreakdownRow, CostBreakdownSortBy, CostSourcesResponse, CostTimeseriesResponse, @@ -16,8 +17,10 @@ import { RequestMode } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" import { TimeWindow } from "../../data/time-range" import { COST_GROUP_BYS } from "../../route/search" +import { perDimension } from "./derive" import * as TimeRangePicker from "../kit/time-range-picker" /** A read's answer; `key` is what it was requested under, so a stale one is dropped. */ @@ -28,12 +31,23 @@ const result = (schema: Schema.Codec) => ({ const GroupBy = Schema.Literals(COST_GROUP_BYS) +/** The filter rail's values: each dimension's keys by cost, under every filter but its own. */ +const FacetRows = Schema.Array(CostBreakdownRow) +export const CostFacets = Schema.Struct(perDimension(() => FacetRows)) +export type CostFacets = typeof CostFacets.Type + export const Message = defineMessageUnion({ CompletedResolveWindow: { window: TimeWindow, nowMs: Schema.Number, mode: RequestMode }, CompletedLoadSources: result(CostSourcesResponse), CompletedLoadTotals: result(CostTotalsResponse), CompletedLoadTimeseries: result(CostTimeseriesResponse), CompletedLoadBreakdown: result(CostBreakdownResponse), + CompletedLoadFacets: result(CostFacets), + /** A rail checkbox: the value joins the dimension's filter, or leaves it. */ + ToggledFacetValue: { dimension: GroupBy, value: Schema.String }, + /** A rail row's Only: the dimension filtered to that value alone. */ + ClickedOnlyFacetValue: { dimension: GroupBy, value: Schema.String }, + GotFilterSectionsMessage: { message: FilterSections.Message }, ClickedReload: {}, CompletedReloadSpin: { version: Schema.Number }, ClickedResetTimeRange: {}, diff --git a/apps/web/src/page/cost/model.ts b/apps/web/src/page/cost/model.ts index 07c172eac8..5c90413e03 100644 --- a/apps/web/src/page/cost/model.ts +++ b/apps/web/src/page/cost/model.ts @@ -1,19 +1,23 @@ import { CostBreakdownResponse, + type CostSourceSummary, CostSourcesResponse, CostTimeseriesResponse, CostTotalsResponse, } from "@maple/domain/http" import * as Policy from "@maple/domain/policy" -import { Schema } from "effect" +import { Option, Schema } from "effect" +import { AsyncData } from "foldkit" import * as Chart from "@maple/views/charts/chart" import { ChartEnvFields } from "@maple/views/charts/chart-env" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" import { TimeWindow } from "../../data/time-range" import { CostSearch } from "../../route/search" +import { CostFacets } from "./message" import * as TimeRangePicker from "../kit/time-range-picker" export type Search = typeof CostSearch.Type @@ -31,6 +35,9 @@ export const Model = Schema.Struct({ totals: KeyedSchema(CostTotalsResponse, LoadFailure), timeseries: KeyedSchema(CostTimeseriesResponse, LoadFailure), breakdown: KeyedSchema(CostBreakdownResponse, LoadFailure), + /** The filter rail's values per dimension. */ + facets: KeyedSchema(CostFacets, LoadFailure), + filterSections: FilterSections.Model, timePicker: TimeRangePicker.Model, chart: Chart.Model, ...ChartEnvFields, @@ -57,3 +64,7 @@ export const canManageSources = (model: Pick): boolean => /** The viewer's roles are known and lack the permission: only then does the page say who has it. */ export const cannotManageSources = (model: Pick): boolean => model.grants !== null && !Policy.has(model.grants, "integrations:manage") + +/** The org's cost sources once read; none before. */ +export const sourcesOf = (model: Pick): ReadonlyArray => + Option.getOrUndefined(AsyncData.getData(model.sources.data))?.sources ?? [] diff --git a/apps/web/src/page/cost/update.ts b/apps/web/src/page/cost/update.ts index bf16c34f56..fb70b385ef 100644 --- a/apps/web/src/page/cost/update.ts +++ b/apps/web/src/page/cost/update.ts @@ -10,6 +10,7 @@ import { failureMessage } from "@maple/views/data/load-failure" import { requestKey } from "@maple/views/data/request-key" import * as Dialog from "@maple/views/ui/dialog" import * as Field from "@maple/views/ui/field" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" import { isClerkAuthEnabled } from "../../platform/auth-mode" import type { Resources } from "../../platform/resources" import { AppRoute, hrefOf } from "../../route" @@ -23,6 +24,8 @@ import { type BreakdownInput, CompleteReloadSpin, FetchCostBreakdown, + FetchCostFacets, + type FacetsInput, FetchCostSources, FetchCostTimeseries, FetchCostTotals, @@ -35,7 +38,11 @@ import { } from "./command" import { DEFAULT_GROUP_BY, + facetFilters, + perDimension, groupByOf, + onlyFilter, + toggledFilter, hasSynced, isUnderADay, requestFilters, @@ -55,6 +62,8 @@ export const DEFAULT_PRESET: PresetValue = "30d" /** The keys that get their own series in the chart; the rest fold into "Other". */ export const SERIES_LIMIT = 8 const BREAKDOWN_LIMIT = 100 +/** The values each rail section can offer, by cost. */ +const FACET_LIMIT = 100 const resolveCommand = (model: Model, context: PageContext, mode: Keyed.RequestMode): Cmd => ResolveWindow({ @@ -103,7 +112,20 @@ const requestData = (model: Model, context: PageContext, mode: Keyed.ReadMode): sortBy: sortOf(model.search), limit: BREAKDOWN_LIMIT, } + const facets: FacetsInput = { + startTime: model.window.startTime, + endTime: model.window.endTime, + filters: perDimension((dimension) => facetFilters(model.search, dimension)), + limit: FACET_LIMIT, + } return applySteps(model, [ + keyed( + model.facets, + requestKey(context.orgId, "costFacets", { data: facets }), + mode, + (next) => ({ facets: next }), + (key) => FetchCostFacets({ key, input: facets }), + ), keyed( model.totals, requestKey(context.orgId, "costTotals", { data: window }), @@ -137,6 +159,8 @@ export const init = (route: { readonly search: Search }, context: PageContext): totals: Keyed.idle(), timeseries: Keyed.idle(), breakdown: Keyed.idle(), + facets: Keyed.idle(), + filterSections: FilterSections.init(), timePicker: TimeRangePicker.init("cost-time-range"), chart: Chart.init(), themeColors: null, @@ -158,6 +182,9 @@ export const init = (route: { readonly search: Search }, context: PageContext): commands: [ resolveCommand(model, context, "Load"), LoadChartRuntime(), + Command.mapMessage(FilterSections.ReadRemembered(), (message) => + Message.GotFilterSectionsMessage({ message }), + ), ...(isClerkAuthEnabled ? [ReadCostGrants()] : []), ], }, @@ -314,6 +341,20 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedSort: ({ sort }) => navigate(model, { ...model.search, sort: sort === "cost" ? undefined : sort }), + CompletedLoadFacets: ({ key, result }) => ({ + model: { ...model, facets: Keyed.accept(model.facets, key, result) }, + }), + ToggledFacetValue: (filter) => navigate(model, toggledFilter(model.search, filter)), + ClickedOnlyFacetValue: (filter) => navigate(model, onlyFilter(model.search, filter)), + GotFilterSectionsMessage: ({ message: inner }) => { + const next = FilterSections.update(model.filterSections, inner) + return { + model: { ...model, filterSections: next.model }, + commands: Command.mapMessages(next.commands ?? [], (sections) => + Message.GotFilterSectionsMessage({ message: sections }), + ), + } + }, ClickedRemoveFilter: (filter) => navigate(model, withoutFilter(model.search, filter)), ClickedClearFilters: () => navigate(model, withoutFilters(model.search)), GotTimePickerMessage: ({ message: inner }) => diff --git a/apps/web/src/page/cost/view-chart.ts b/apps/web/src/page/cost/view-chart.ts index 91d5e804d5..7be3ae768f 100644 --- a/apps/web/src/page/cost/view-chart.ts +++ b/apps/web/src/page/cost/view-chart.ts @@ -1,5 +1,6 @@ -import type { CostTimeseriesResponse } from "@maple/domain/http" -import { Option } from "effect" +import type { CostSourceSummary, CostTimeseriesResponse } from "@maple/domain/http" +import type { SeriesMark } from "@maple/views/charts/timeseries-legend" +import { Array as Arr, Option, Record as Rec } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as BarChart from "@maple/views/charts/bar-chart" @@ -9,9 +10,18 @@ import { errorState } from "@maple/views/ui/error-state" import { panelChartBody } from "@maple/views/ui/panel" import { chartCard } from "../kit/chart-card" import { lastBy } from "../kit/last-by" -import { chartRows, type CostGroupBy, DIMENSIONS, formatDay, groupByOf, isEmptySeries } from "./derive" +import { + chartRows, + type CostGroupBy, + DIMENSIONS, + formatDay, + groupByOf, + isEmptySeries, + seriesLabels, +} from "./derive" +import { keyMark } from "./marks" import { Message } from "./message" -import type { Model } from "./model" +import { sourcesOf, type Model } from "./model" type H = HtmlBuilder @@ -20,8 +30,30 @@ const CHART_ID = "cost-daily-chart" const CHART_HEIGHT = 300 /** The chart's rows, kept while the answer and the grouping are: a hover re-renders over the same array. */ -const rowsOf = lastBy(([response, dimension]: readonly [CostTimeseriesResponse, CostGroupBy]) => - chartRows(response, dimension), +const rowsOf = lastBy( + ([response, dimension, sources]: readonly [ + CostTimeseriesResponse, + CostGroupBy, + ReadonlyArray, + ]) => chartRows(response, dimension, sources), +) + +/** Each series' mark in the legend, by the name it plots under. */ +const marksOf = lastBy( + ([response, dimension, sources]: readonly [ + CostTimeseriesResponse, + CostGroupBy, + ReadonlyArray, + ]): Readonly> => { + const labels = seriesLabels(dimension, response.series, sources) + return Rec.fromEntries( + Arr.flatMap(response.series, (series, index) => { + const mark = series.other ? null : keyMark(dimension, series.key, sources) + const label = labels[index] + return mark === null || label === undefined ? [] : [[label, mark] as const] + }), + ) + }, ) /** Under the title: the unit of the bars, and what the lighter ones mean. */ @@ -39,6 +71,7 @@ const descriptionOf = (model: Model): string => { const plot = (h: H, model: Model, response: CostTimeseriesResponse): Html => { const theme = model.chartRuntime._tag === "Ready" ? model.themeColors : null if (theme === null) return chartLoading(h, { variant: "bar", height: CHART_HEIGHT }) + const inputsKey = [response, groupByOf(model.search), sourcesOf(model)] as const return h.div( [h.Class("w-full"), h.Style({ height: `${CHART_HEIGHT}px` })], [ @@ -48,7 +81,8 @@ const plot = (h: H, model: Model, response: CostTimeseriesResponse): Html => { view: BarChart.view, viewInputs: { chartId: CHART_ID, - data: rowsOf([response, groupByOf(model.search)]), + data: rowsOf(inputsKey), + seriesMarks: marksOf(inputsKey), theme, // Cost days are UTC calendar days, whatever the viewer's zone. timeZone: "UTC", diff --git a/apps/web/src/page/cost/view-filters.ts b/apps/web/src/page/cost/view-filters.ts new file mode 100644 index 0000000000..44aabcae1e --- /dev/null +++ b/apps/web/src/page/cost/view-filters.ts @@ -0,0 +1,103 @@ +import { cn } from "@maple/ui/lib/utils" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" +import { filterSection } from "@maple/views/ui/filters/filter-section" +import { + filterSidebarBody, + filterSidebarError, + filterSidebarFrame, + filterSidebarHeader, + filterSidebarLoading, +} from "@maple/views/ui/filters/filter-sidebar" +import { activeFilters, type CostGroupBy, DIMENSIONS, keyLabel } from "./derive" +import { keyDetail, keyMark } from "./marks" +import { type CostFacets, Message } from "./message" +import { sourcesOf, type Model } from "./model" + +type H = HtmlBuilder + +/** The rail's sections, top to bottom: who bills, then what, then where. */ +const RAIL_ORDER = [ + "provider", + "account", + "cloud_service", + "service_category", + "region", +] as const satisfies ReadonlyArray + +/** Long lists get a search field. */ +const SEARCHABLE = new Set(["cloud_service", "region", "account"]) + +const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) + +const compactFormats = new Map() + +/** A facet's cost, short enough for the rail: "$2.4K"; a plain compact number without a currency. */ +const compactMoney = (currency: string) => { + const code = /^[A-Za-z]{3}$/u.test(currency) ? currency : "" + const cached = compactFormats.get(code) + const format = + cached ?? + (code === "" + ? new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }) + : new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, + style: "currency", + currency: code, + })) + compactFormats.set(code, format) + return (amount: number) => format.format(Math.abs(amount) < 0.005 ? 0 : amount) +} + +const currencyOf = (model: Model): string => + Option.getOrUndefined(AsyncData.getData(model.totals.data))?.currency ?? "" + +const section = (h: H, model: Model, facets: CostFacets, dimension: CostGroupBy): Html => { + const sources = sourcesOf(model) + return filterSection(h, { + title: DIMENSIONS[dimension].label, + options: Arr.map(facets[dimension], (row) => ({ name: row.key, count: row.cost })), + selected: model.search[dimension] ?? [], + isSearchable: SEARCHABLE.has(dimension), + sections: model.filterSections, + toSectionsMessage: toSections, + onToggle: (value) => Message.ToggledFacetValue({ dimension, value }), + onOnly: (value) => Message.ClickedOnlyFacetValue({ dimension, value }), + getOptionLabel: (value) => keyLabel(dimension, value, sources), + getOptionDescription: (value) => { + const detail = keyDetail(dimension, value, sources) + const label = keyLabel(dimension, value, sources) + return detail === null ? label : `${label} · ${detail}` + }, + renderOptionIcon: (value) => { + const mark = keyMark(dimension, value, sources) + return mark === null + ? undefined + : icon(h, mark.icon, { className: cn("size-3.5 shrink-0", mark.className) }) + }, + formatCount: compactMoney(currencyOf(model)), + }) +} + +/** The cost filter rail: one checkbox section per dimension, each value with its logo and cost. */ +export const filtersRail = (h: H, model: Model): Html => + AsyncData.matchData(model.facets.data, { + onEmpty: () => filterSidebarLoading(h, RAIL_ORDER.length), + onFailure: (failure) => filterSidebarError(h, failure, Message.ClickedRetry()), + onData: (facets) => + filterSidebarFrame(h, { isWaiting: AsyncData.isRefreshing(model.facets.data) }, [ + filterSidebarHeader( + h, + activeFilters(model.search).length > 0 ? { onClear: Message.ClickedClearFilters() } : {}, + ), + ...filterSidebarBody( + h, + "cost-filters", + Arr.map(RAIL_ORDER, (dimension) => section(h, model, facets, dimension)), + ), + ]), + }) diff --git a/apps/web/src/page/cost/view-table.ts b/apps/web/src/page/cost/view-table.ts index eb47c8dca6..cba6e37073 100644 --- a/apps/web/src/page/cost/view-table.ts +++ b/apps/web/src/page/cost/view-table.ts @@ -1,5 +1,6 @@ import type { CostBreakdownResponse, CostBreakdownRow, CostBreakdownSortBy } from "@maple/domain/http" import { EMPTY_VALUE, formatPercent } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" import { refreshingClass } from "@maple/ui/lib/refreshing" import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" import { Option, Array as Arr } from "effect" @@ -10,6 +11,7 @@ import { delta } from "@maple/views/ui/delta" import { emptyMessage } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" import { panel, panelHeader } from "@maple/views/ui/panel" +import { icon } from "@maple/views/ui/icon" import { hstack } from "@maple/views/ui/stack" import { swatch } from "@maple/views/ui/swatch" import { @@ -29,6 +31,7 @@ import { truncatedText } from "@maple/views/ui/truncated-text" import { typography } from "@maple/views/ui/typography" import { DIMENSIONS, + drilledInto, formatMoney, formatMoneyChange, groupByOf, @@ -38,10 +41,10 @@ import { seriesLabels, sortOf, type CostGroupBy, - withFilter, } from "./derive" +import { keyDetail, keyMark } from "./marks" import { Message } from "./message" -import type { Model } from "./model" +import { sourcesOf, type Model } from "./model" type H = HtmlBuilder @@ -85,7 +88,7 @@ const head = (h: H, dimension: CostGroupBy, sort: CostBreakdownSortBy): Html => const seriesColors = (model: Model, dimension: CostGroupBy): ReadonlyMap => { const series = Option.getOrUndefined(AsyncData.getData(model.timeseries.data))?.series ?? [] // The same name set the bar chart resolves, "Other" included: a colour depends on the whole set. - const labels = seriesLabels(dimension, series) + const labels = seriesLabels(dimension, series, sourcesOf(model)) const colors = resolveSeriesColors(labels) return new Map( Arr.flatMap(series, (entry, index) => { @@ -118,10 +121,26 @@ const row = ( totals.days, model.search, ) === "synced" - const label = keyLabel(dimension, entry.key) + const sources = sourcesOf(model) + const label = keyLabel(dimension, entry.key, sources) const filter = { dimension, value: entry.key } const color = colors.get(entry.key) - const name = truncatedText(h, { text: label, className: "text-xs" }) + const mark = keyMark(dimension, entry.key, sources) + const detail = keyDetail(dimension, entry.key, sources) + const next = drilledInto(model.search, filter) + const name = hstack(h, { gap: "2", shrink: true }, [ + ...(mark === null + ? [] + : [icon(h, mark.icon, { size: 14, className: cn("shrink-0", mark.className) })]), + truncatedText(h, { text: label, className: "text-xs" }), + ...(detail === null + ? [] + : [ + typography(h, { size: "xs", tone: "muted", truncate: true, className: "shrink-[2]" }, [ + detail, + ]), + ]), + ]) return tableRow( h, [ @@ -138,12 +157,14 @@ const row = ( : rowLink( h, { - href: hrefOf( - AppRoute.Cost({ search: withFilter(model.search, filter) }), - ), + href: hrefOf(AppRoute.Cost({ search: next })), className: "block min-w-0", attributes: [ - h.Title(`Filter to ${DIMENSIONS[dimension].label}: ${label}`), + h.Title( + next.groupBy === dimension + ? `Filter to ${DIMENSIONS[dimension].label}: ${label}` + : `${label} by ${DIMENSIONS[next.groupBy ?? dimension].noun}`, + ), ], }, [name], @@ -230,7 +251,7 @@ export const breakdownTable = (h: H, model: Model): Html => { return panel(h, {}, [ panelHeader(h, { title: `Cost by ${DIMENSIONS[dimension].noun}`, - action: typography(h, { size: "2xs", tone: "subtle" }, ["Select a row to filter to it"]), + action: typography(h, { size: "2xs", tone: "subtle" }, ["Select a row to break it down"]), }), AsyncData.matchData(model.breakdown.data, { onEmpty: () => loading(h, dimension), diff --git a/apps/web/src/page/cost/view.ts b/apps/web/src/page/cost/view.ts index 4479537619..f379aa8441 100644 --- a/apps/web/src/page/cost/view.ts +++ b/apps/web/src/page/cost/view.ts @@ -37,10 +37,12 @@ import { DAY_RANGE_PRESET_VALUES } from "../kit/time-controls" import { activeFilters, DIMENSIONS, groupByOf, hasSynced, keyLabel } from "./derive" import { Message } from "./message" import { LONG_RANGE_MAX_SECONDS } from "../../route/session-time-range" -import { canManageSources, cannotManageSources, type Model } from "./model" +import { keyMark } from "./marks" +import { canManageSources, cannotManageSources, sourcesOf, type Model } from "./model" import { DEFAULT_PRESET } from "./update" import { addSourceDialog, removeSourceDialog } from "./view-add-source" import { dailyChart } from "./view-chart" +import { filtersRail } from "./view-filters" import { figuresRow } from "./view-figures" import { sourcesSection } from "./view-sources" import { breakdownTable } from "./view-table" @@ -63,7 +65,7 @@ const onRamp = ( emptyMedia(h, { variant: "icon" }, [icon(h, "CircleDollarIcon")]), emptyTitle(h, ["No cost source yet"]), emptyDescription(h, [ - "Add a source and Maple reads what it bills each day: an AWS account by service, region and usage type, an OpenRouter account by model.", + "Add a source and Maple reads what it bills each day: an AWS account by service, region and account, an OpenRouter account by model.", ]), // A member's save would be refused: say who can, and offer no form. ...(!options.cannotManage @@ -173,7 +175,8 @@ const toolbar = (h: H, model: Model): Html => { chips: Arr.map(activeFilters(model.search), (filter) => ({ id: `${filter.dimension}:${filter.value}`, label: DIMENSIONS[filter.dimension].label, - values: [keyLabel(filter.dimension, filter.value)], + values: [keyLabel(filter.dimension, filter.value, sourcesOf(model))], + mark: keyMark(filter.dimension, filter.value, sourcesOf(model)) ?? undefined, onRemove: Message.ClickedRemoveFilter(filter), })), onClearAll: Message.ClickedClearFilters(), @@ -222,9 +225,6 @@ const content = (h: H, model: Model): ReadonlyArray => { ] } -const sourcesOf = (model: Model): ReadonlyArray => - Option.getOrUndefined(AsyncData.getData(model.sources.data))?.sources ?? [] - /** Whether the window's reads are on the page: only then does the header offer a day range. */ const showsData = (model: Model): boolean => Option.exists(AsyncData.getData(model.sources.data), (response) => hasSynced(response.sources)) @@ -261,6 +261,8 @@ export const view = Submodel.defineView((model, h, { title: "Cost", + // The rail filters the window's reads: it shows once there is cost to read. + filters: showsData(model) ? filtersRail(h, model) : undefined, actions: showsData(model) ? pageTime(h, { search: model.search, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 998eea40f6..30b707057d 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -566,14 +566,7 @@ export const AnomaliesSearch = Schema.Struct({ export const CodeReviewSearch = Schema.Struct(CodeReviewSearchFields) /** The dimensions `/cost` groups by and filters on (a subset of `CostDimension`). */ -export const COST_GROUP_BYS = [ - "cloud_service", - "provider", - "service_category", - "region", - "usage_type", - "account", -] as const +export const COST_GROUP_BYS = ["cloud_service", "provider", "service_category", "region", "account"] as const /** * `/cost`: the day range, the grouping (`cloud_service` when absent), the table's order (`cost` @@ -587,7 +580,6 @@ export const CostSearch = Schema.Struct({ provider: OptionalStringArrayParam, service_category: OptionalStringArrayParam, region: OptionalStringArrayParam, - usage_type: OptionalStringArrayParam, account: OptionalStringArrayParam, }) diff --git a/packages/views/src/charts/bar-chart.ts b/packages/views/src/charts/bar-chart.ts index 6bcc89e703..ba1c5b9ec8 100644 --- a/packages/views/src/charts/bar-chart.ts +++ b/packages/views/src/charts/bar-chart.ts @@ -4,7 +4,7 @@ import { Submodel } from "foldkit" import type { Attribute, Html } from "foldkit/html" import type { Message, Model } from "./chart" import { MeasureChartContainer } from "./chart-mounts" -import { timeseriesLegend } from "./timeseries-legend" +import { type SeriesMark, timeseriesLegend } from "./timeseries-legend" import { bucketTimeseries, MAX_BAR_SERIES, OTHER_LABEL } from "./series/bucket-series" import { ChartSpec, ChartTooltip, rowsCache, specCache, type TimeseriesAxisSpec } from "./chart-spec" import { bucketTooltipBody } from "./bucket-tooltip" @@ -39,6 +39,8 @@ export interface BarChartInputs { readonly axis?: TimeseriesAxisSpec | undefined readonly legend?: "hidden" | "visible" | "right" | undefined readonly seriesStats?: boolean + /** A mark beside each series' name in the compact legend, by the series' label. */ + readonly seriesMarks?: Readonly> | undefined } type Row = Readonly> @@ -83,6 +85,7 @@ export const view = Submodel.defineView((model, key: definition.chartKey, label: definition.rawKey, color: colors[definition.chartKey] ?? SERIES_FALLBACK_COLOR, + mark: inputs.seriesMarks?.[definition.rawKey], })) const tooltipBody = (portal: Attribute): Option.Option => Option.flatMap(model.tooltip, (tooltip) => diff --git a/packages/views/src/charts/timeseries-legend.ts b/packages/views/src/charts/timeseries-legend.ts index 36f981ce52..c897d34327 100644 --- a/packages/views/src/charts/timeseries-legend.ts +++ b/packages/views/src/charts/timeseries-legend.ts @@ -17,14 +17,22 @@ import { hstack } from "../ui/stack" import { swatch } from "../ui/swatch" import { Array as Arr } from "effect" import { typography } from "../ui/typography" +import { icon, type IconName } from "../ui/icon" // The timeseries legend: the compact legend or the Min/Max/Mean/Last table, below or beside the // plot; shared by the line, area and bar charts. +/** A mark drawn beside a series' name: a provider's logo, a model vendor's, a category's glyph. */ +export interface SeriesMark { + readonly icon: IconName + readonly className?: string +} + export interface LegendSeries { readonly key: string readonly label: string readonly color: string + readonly mark?: SeriesMark | undefined } type LegendLayout = Readonly<{ isRight: boolean; maxHeight: number | null }> @@ -65,7 +73,18 @@ const compactLegend = ( ), attributes: [h.Key(entry.key), h.Title(entry.label)], }, - [swatch(h, { color: entry.color }), typography(h, { truncate: true }, [entry.label])], + [ + swatch(h, { color: entry.color }), + ...(entry.mark === undefined + ? [] + : [ + icon(h, entry.mark.icon, { + size: 12, + className: cn("shrink-0", entry.mark.className), + }), + ]), + typography(h, { truncate: true }, [entry.label]), + ], ), ), ) diff --git a/packages/views/src/lib/ai-vendor.ts b/packages/views/src/lib/ai-vendor.ts index fe4d3b11f9..a2c02c2ac7 100644 --- a/packages/views/src/lib/ai-vendor.ts +++ b/packages/views/src/lib/ai-vendor.ts @@ -1,4 +1,4 @@ -import { Array as Arr, Record as Rec, String as Str } from "effect" +import { Array as Arr, Option, Record as Rec, String as Str } from "effect" import type { IconName } from "../ui/icon" // The AI vendors the ingest gateway stamps (`VENDORS` in apps/ingest/src/ai_session.rs): one record @@ -174,3 +174,14 @@ export const modelVendorIcon = (detected: { export function modelVendorColor(detected: { readonly brandColor: BrandColor | null }): string | undefined { return detected.brandColor === null ? undefined : brandColorCss(detected.brandColor) } + +/** + * The mark of a `vendor/model` slug as OpenRouter names models (`anthropic/claude-sonnet-4.5`): + * the model family's when its name has one, then the vendor's, then the generic mark. + */ +export const modelSlugIcon = (slug: string): IconName => { + const [vendor, ...rest] = Str.split("/")(Str.toLowerCase(slug)) + const model = Arr.join(rest, "/") + const family = Arr.findFirst([...FAMILY_ICONS.keys()], (name) => Str.includes(name)(model)) + return modelVendorIcon({ vendorSlug: vendor, family: Option.getOrNull(family) }) +} diff --git a/packages/views/src/ui/filters/active-filter-chips.ts b/packages/views/src/ui/filters/active-filter-chips.ts index 2cd1329ef8..adef81d844 100644 --- a/packages/views/src/ui/filters/active-filter-chips.ts +++ b/packages/views/src/ui/filters/active-filter-chips.ts @@ -1,7 +1,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "../button" -import { icon } from "../icon" +import { icon, type IconName } from "../icon" import { iconButton } from "../icon-button" import { Array as Arr } from "effect" @@ -19,6 +19,8 @@ export type ActiveFilterChip = Readonly<{ valueLabel?: (value: string) => string /** The connector in place of is / is not (`contains`, `>`, `is set`); empty shows none. */ operator?: string + /** A mark before the label: the value's provider, vendor or kind. */ + mark?: Readonly<{ icon: IconName; className?: string | undefined }> | undefined }> const summarize = (chip: ActiveFilterChip): string => { @@ -58,6 +60,14 @@ export const activeFilterChips = ( ), ], [ + ...(chip.mark === undefined + ? [] + : [ + icon(h, chip.mark.icon, { + size: 12, + className: cn("shrink-0", chip.mark.className), + }), + ]), h.span( [h.Class("truncate")], [ diff --git a/packages/views/src/ui/filters/filter-section.ts b/packages/views/src/ui/filters/filter-section.ts index dc881cefb2..f8d6912d23 100644 --- a/packages/views/src/ui/filters/filter-section.ts +++ b/packages/views/src/ui/filters/filter-section.ts @@ -100,6 +100,8 @@ export type FilterSectionConfig = Readonly<{ /** The label's `title` when it is more than the label. */ getOptionDescription?: (name: string) => string | undefined showCounts?: boolean + /** How a row's count reads (an amount of money); a compact number when absent. */ + formatCount?: (count: number) => string /** The info mark beside the title, this text in its tooltip. */ description?: string }> @@ -137,7 +139,12 @@ const rowAction = ( [text], ) -const countText = (h: HtmlBuilder, option: FilterOption, isTriState: boolean): Html => +const countText = ( + h: HtmlBuilder, + option: FilterOption, + isTriState: boolean, + format: (count: number) => string = formatNumber, +): Html => h.span( [ h.Class( @@ -149,7 +156,7 @@ const countText = (h: HtmlBuilder, option: FilterOption, isTriState: boole ), h.Title(option.count.toLocaleString("en-US")), ], - [formatNumber(option.count)], + [format(option.count)], ) /** The info mark beside a filter's title, its description in a tooltip. */ @@ -257,7 +264,9 @@ const optionRow = (h: HtmlBuilder, config: FilterSectionConfig, option: h.span( [h.Class("relative flex shrink-0 items-center justify-end")], [ - ...(config.showCounts === false ? [] : [countText(h, option, isTriState)]), + ...(config.showCounts === false + ? [] + : [countText(h, option, isTriState, config.formatCount)]), ...(config.onExclude === undefined ? [] : [