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 ? [] : [