| import type { UsageRecord } from "./types" |
|
|
| export const LIVE_WINDOW_SECONDS = 60 |
|
|
| export const RANGES = [ |
| { key: "15m", label: "15m", seconds: 15 * 60 }, |
| { key: "1h", label: "1h", seconds: 60 * 60 }, |
| { key: "6h", label: "6h", seconds: 6 * 60 * 60 }, |
| { key: "24h", label: "24h", seconds: 24 * 60 * 60 }, |
| { key: "all", label: "All", seconds: null }, |
| ] as const |
|
|
| export type RangeKey = (typeof RANGES)[number]["key"] |
|
|
| |
| |
| |
| export const LIGHT_CHART_COLORS = [ |
| "#2a78d6", |
| "#eb6834", |
| "#1baf7a", |
| "#eda100", |
| "#e87ba4", |
| "#008300", |
| "#4a3aa7", |
| "#e34948", |
| ] as const |
|
|
| export const DARK_CHART_COLORS = [ |
| "#3987e5", |
| "#d95926", |
| "#199e70", |
| "#c98500", |
| "#d55181", |
| "#008300", |
| "#9085e9", |
| "#e66767", |
| ] as const |
|
|
| |
| export const CHART_COLORS = LIGHT_CHART_COLORS |
|
|
| export function chartPalette(dark: boolean): readonly string[] { |
| return dark ? DARK_CHART_COLORS : LIGHT_CHART_COLORS |
| } |
|
|
| |
| export const TOKEN_COLORS = { |
| input: "#2a78d6", |
| output: "#eb6834", |
| cacheRead: "#1baf7a", |
| cacheWrite: "#eda100", |
| reasoning: "#e87ba4", |
| } as const |
|
|
| export const TOKEN_COLORS_DARK = { |
| input: "#3987e5", |
| output: "#d95926", |
| cacheRead: "#199e70", |
| cacheWrite: "#c98500", |
| reasoning: "#d55181", |
| } as const |
|
|
| export type TokenSlice = keyof typeof TOKEN_COLORS |
|
|
| export function tokenColor(key: TokenSlice, dark: boolean): string { |
| return (dark ? TOKEN_COLORS_DARK : TOKEN_COLORS)[key] |
| } |
|
|
| |
| |
| export const STATUS_COLORS = { |
| success: "#0ca30c", |
| error: "#d03b3b", |
| cancelled: "#fab219", |
| } as const |
|
|
| |
| |
| export const HEATMAP_LEVEL_COLORS_LIGHT = ["#86b6ef", "#6da7ec", "#3987e5", "#1c5cab"] |
| export const HEATMAP_LEVEL_COLORS_DARK = ["#5598e7", "#2a78d6", "#256abf", "#184f95"] |
| export const HEATMAP_EMPTY_LIGHT = "#f0efec" |
| export const HEATMAP_EMPTY_DARK = "#383835" |
| export const HEATMAP_PEAK_RING_LIGHT = "#2a78d6" |
| export const HEATMAP_PEAK_RING_DARK = "#5598e7" |
| |
| export const HEATMAP_LABEL_ON_LIGHT = "#1c5cab" |
|
|
| export interface TimeBucket { |
| start: number |
| label: string |
| count: number |
| success: number |
| error: number |
| cancelled: number |
| tokens: number |
| successTokens: number |
| errorTokens: number |
| cancelledTokens: number |
| cacheReadTokens: number |
| cacheWriteTokens: number |
| reasoningTokens: number |
| latencySum: number |
| latencyCount: number |
| bucketSeconds: number |
| } |
|
|
| export interface Kpis { |
| requests: number |
| inputTokens: number |
| outputTokens: number |
| cacheReadTokens: number |
| cacheWriteTokens: number |
| reasoningTokens: number |
| errors: number |
| cancelled: number |
| tpm: number |
| tps: number |
| avgLatencyMs: number |
| errorRate: number |
| } |
|
|
| export interface DonutDatum { |
| name: string |
| value: number |
| color: string |
| } |
|
|
| export interface EndpointUsage { |
| name: string |
| tokens: number |
| requests: number |
| errors: number |
| } |
|
|
| export interface SankeyNode { |
| name: string |
| display: string |
| category: 0 | 1 | 2 |
| } |
|
|
| export interface SankeyLink { |
| source: string |
| target: string |
| value: number |
| } |
|
|
| export interface HeatmapCell { |
| hour: number |
| day: number |
| count: number |
| tokens: number |
| latencySum: number |
| latencyCount: number |
| } |
|
|
| export const HEATMAP_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const |
| export const HEATMAP_HOURS = Array.from({ length: 24 }, (_, index) => |
| String(index).padStart(2, "0"), |
| ) |
|
|
| export function filterByRange( |
| records: UsageRecord[], |
| range: RangeKey, |
| now: number, |
| ): UsageRecord[] { |
| const option = RANGES.find((candidate) => candidate.key === range) |
| if (!option || option.seconds === null) return records |
| return records.filter((record) => record.timestamp >= now - option.seconds) |
| } |
|
|
| function chooseBucketSeconds(spanSeconds: number): number { |
| if (spanSeconds <= 5 * 60) return 10 |
| if (spanSeconds <= 30 * 60) return 30 |
| if (spanSeconds <= 2 * 60 * 60) return 60 |
| if (spanSeconds <= 12 * 60 * 60) return 300 |
| if (spanSeconds <= 48 * 60 * 60) return 900 |
| return 3600 |
| } |
|
|
| function bucketLabel(start: number, bucketSeconds: number): string { |
| const date = new Date(start * 1000) |
| if (bucketSeconds < 60) { |
| return date.toLocaleTimeString(undefined, { |
| hour: "2-digit", |
| minute: "2-digit", |
| second: "2-digit", |
| }) |
| } |
| if (bucketSeconds < 3600) { |
| return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) |
| } |
| return date.toLocaleString(undefined, { |
| month: "short", |
| day: "numeric", |
| hour: "2-digit", |
| minute: "2-digit", |
| }) |
| } |
|
|
| export function buildBuckets(records: UsageRecord[], now: number): TimeBucket[] { |
| if (records.length === 0) return [] |
| let minTs = Number.POSITIVE_INFINITY |
| let maxTs = 0 |
| for (const record of records) { |
| if (record.timestamp < minTs) minTs = record.timestamp |
| if (record.timestamp > maxTs) maxTs = record.timestamp |
| } |
| const span = Math.max(now - minTs, maxTs - minTs, 1) |
| let bucketSeconds = chooseBucketSeconds(span) |
| while (span / bucketSeconds > 180) bucketSeconds *= 2 |
|
|
| const first = Math.floor(minTs / bucketSeconds) * bucketSeconds |
| const last = Math.max(Math.floor(now / bucketSeconds) * bucketSeconds, first) |
| const buckets: TimeBucket[] = [] |
| const byStart = new Map<number, TimeBucket>() |
| for (let start = first; start <= last; start += bucketSeconds) { |
| const bucket: TimeBucket = { |
| start, |
| label: bucketLabel(start, bucketSeconds), |
| count: 0, |
| success: 0, |
| error: 0, |
| cancelled: 0, |
| tokens: 0, |
| successTokens: 0, |
| errorTokens: 0, |
| cancelledTokens: 0, |
| cacheReadTokens: 0, |
| cacheWriteTokens: 0, |
| reasoningTokens: 0, |
| latencySum: 0, |
| latencyCount: 0, |
| bucketSeconds, |
| } |
| buckets.push(bucket) |
| byStart.set(start, bucket) |
| } |
| for (const record of records) { |
| const bucket = byStart.get(Math.floor(record.timestamp / bucketSeconds) * bucketSeconds) |
| if (!bucket) continue |
| const tokens = record.input_tokens + record.output_tokens |
| bucket.count += 1 |
| bucket.tokens += tokens |
| bucket.cacheReadTokens += record.cache_read_tokens |
| bucket.cacheWriteTokens += record.cache_creation_tokens |
| bucket.reasoningTokens += record.reasoning_tokens |
| bucket.latencySum += record.duration_ms |
| bucket.latencyCount += 1 |
| if (record.status === "success") { |
| bucket.success += 1 |
| bucket.successTokens += tokens |
| } else if (record.status === "error") { |
| bucket.error += 1 |
| bucket.errorTokens += tokens |
| } else { |
| bucket.cancelled += 1 |
| bucket.cancelledTokens += tokens |
| } |
| } |
| return buckets |
| } |
|
|
| export function computeKpis(records: UsageRecord[], now: number): Kpis { |
| let requests = 0 |
| let inputTokens = 0 |
| let outputTokens = 0 |
| let cacheReadTokens = 0 |
| let cacheWriteTokens = 0 |
| let reasoningTokens = 0 |
| let errors = 0 |
| let cancelled = 0 |
| let latencySum = 0 |
| let liveInputTokens = 0 |
| let liveOutputTokens = 0 |
| const liveCutoff = now - LIVE_WINDOW_SECONDS |
| for (const record of records) { |
| requests += 1 |
| inputTokens += record.input_tokens |
| outputTokens += record.output_tokens |
| cacheReadTokens += record.cache_read_tokens |
| cacheWriteTokens += record.cache_creation_tokens |
| reasoningTokens += record.reasoning_tokens |
| latencySum += record.duration_ms |
| if (record.status === "error") errors += 1 |
| else if (record.status === "cancelled") cancelled += 1 |
| if (record.timestamp >= liveCutoff) { |
| liveInputTokens += record.input_tokens |
| liveOutputTokens += record.output_tokens |
| } |
| } |
| return { |
| requests, |
| inputTokens, |
| outputTokens, |
| cacheReadTokens, |
| cacheWriteTokens, |
| reasoningTokens, |
| errors, |
| cancelled, |
| tpm: ((liveInputTokens + liveOutputTokens) / LIVE_WINDOW_SECONDS) * 60, |
| tps: liveOutputTokens / LIVE_WINDOW_SECONDS, |
| avgLatencyMs: requests > 0 ? latencySum / requests : 0, |
| errorRate: requests > 0 ? errors / requests : 0, |
| } |
| } |
|
|
| export function buildTokenMix(kpis: Kpis, dark: boolean): DonutDatum[] { |
| return [ |
| { name: "Input", value: kpis.inputTokens, color: tokenColor("input", dark) }, |
| { name: "Output", value: kpis.outputTokens, color: tokenColor("output", dark) }, |
| { name: "Cache read", value: kpis.cacheReadTokens, color: tokenColor("cacheRead", dark) }, |
| { name: "Cache write", value: kpis.cacheWriteTokens, color: tokenColor("cacheWrite", dark) }, |
| { name: "Reasoning", value: kpis.reasoningTokens, color: tokenColor("reasoning", dark) }, |
| ].filter((datum) => datum.value > 0) |
| } |
|
|
| function buildTop( |
| records: UsageRecord[], |
| keyOf: (record: UsageRecord) => string, |
| limit: number, |
| ): EndpointUsage[] { |
| const byName = new Map<string, EndpointUsage>() |
| for (const record of records) { |
| const name = keyOf(record) || "(unknown)" |
| const entry = byName.get(name) ?? { name, tokens: 0, requests: 0, errors: 0 } |
| entry.tokens += record.input_tokens + record.output_tokens |
| entry.requests += 1 |
| if (record.status === "error") entry.errors += 1 |
| byName.set(name, entry) |
| } |
| return [...byName.values()].sort((a, b) => b.tokens - a.tokens).slice(0, limit) |
| } |
|
|
| export function buildTopByModel(records: UsageRecord[], limit = 6): EndpointUsage[] { |
| return buildTop( |
| records, |
| (record) => record.gateway_model || record.provider_model || "(no model)", |
| limit, |
| ) |
| } |
|
|
| export function buildTopByProvider(records: UsageRecord[], limit = 6): EndpointUsage[] { |
| return buildTop(records, (record) => record.provider, limit) |
| } |
|
|
| const MODEL_LIMIT = 5 |
| const PROVIDER_LIMIT = 4 |
|
|
| export function buildRoutingSankey(records: UsageRecord[]): { |
| nodes: SankeyNode[] |
| links: SankeyLink[] |
| } { |
| const wireCounts = new Map<string, number>() |
| const modelCounts = new Map<string, number>() |
| const providerCounts = new Map<string, number>() |
| for (const record of records) { |
| const wire = record.wire_api || "unknown" |
| const model = record.gateway_model || record.provider_model || "(no model)" |
| const provider = record.provider || "(unknown)" |
| wireCounts.set(wire, (wireCounts.get(wire) ?? 0) + 1) |
| modelCounts.set(model, (modelCounts.get(model) ?? 0) + 1) |
| providerCounts.set(provider, (providerCounts.get(provider) ?? 0) + 1) |
| } |
| const topModels = [...modelCounts.entries()] |
| .sort((a, b) => b[1] - a[1]) |
| .slice(0, MODEL_LIMIT) |
| const topProviders = [...providerCounts.entries()] |
| .sort((a, b) => b[1] - a[1]) |
| .slice(0, PROVIDER_LIMIT) |
| const modelBucket = (name: string): string => |
| topModels.some(([candidate]) => candidate === name) ? `gw:${name}` : "gw:Other" |
| const providerBucket = (name: string): string => |
| topProviders.some(([candidate]) => candidate === name) ? `pv:${name}` : "pv:Other" |
|
|
| const linkCounts = new Map<string, number>() |
| const addLink = (source: string, target: string) => { |
| const key = `${source}\u0000${target}` |
| linkCounts.set(key, (linkCounts.get(key) ?? 0) + 1) |
| } |
| for (const record of records) { |
| const wire = record.wire_api || "unknown" |
| const model = record.gateway_model || record.provider_model || "(no model)" |
| const provider = record.provider || "(unknown)" |
| addLink(wire, modelBucket(model)) |
| addLink(modelBucket(model), providerBucket(provider)) |
| } |
|
|
| const nodes: SankeyNode[] = [] |
| for (const wire of wireCounts.keys()) { |
| nodes.push({ name: wire, display: wire, category: 0 }) |
| } |
| for (const [name] of topModels) { |
| nodes.push({ name: `gw:${name}`, display: name, category: 1 }) |
| } |
| if (modelCounts.size > MODEL_LIMIT) { |
| nodes.push({ name: "gw:Other", display: "Other models", category: 1 }) |
| } |
| for (const [name] of topProviders) { |
| nodes.push({ name: `pv:${name}`, display: name, category: 2 }) |
| } |
| if (providerCounts.size > PROVIDER_LIMIT) { |
| nodes.push({ name: "pv:Other", display: "Other providers", category: 2 }) |
| } |
| const links: SankeyLink[] = [...linkCounts.entries()] |
| .map(([key, value]) => { |
| const [source, target] = key.split("\u0000") |
| return { source, target, value } |
| }) |
| .sort((a, b) => b.value - a.value) |
| return { nodes, links } |
| } |
|
|
| export function buildHourHeatmap(records: UsageRecord[]): HeatmapCell[] { |
| const cells = new Map<number, HeatmapCell>() |
| for (const record of records) { |
| const date = new Date(record.timestamp * 1000) |
| const day = (date.getDay() + 6) % 7 |
| const hour = date.getHours() |
| const key = day * 24 + hour |
| const cell = cells.get(key) ?? { |
| hour, |
| day, |
| count: 0, |
| tokens: 0, |
| latencySum: 0, |
| latencyCount: 0, |
| } |
| cell.count += 1 |
| cell.tokens += record.input_tokens + record.output_tokens |
| cell.latencySum += record.duration_ms |
| cell.latencyCount += 1 |
| cells.set(key, cell) |
| } |
| const all: HeatmapCell[] = [] |
| for (let day = 0; day < 7; day += 1) { |
| for (let hour = 0; hour < 24; hour += 1) { |
| all.push( |
| cells.get(day * 24 + hour) ?? { |
| hour, |
| day, |
| count: 0, |
| tokens: 0, |
| latencySum: 0, |
| latencyCount: 0, |
| }, |
| ) |
| } |
| } |
| return all |
| } |
|
|
| export function formatTokens(value: number): string { |
| if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` |
| if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` |
| return String(value) |
| } |
|
|
| export function formatDuration(durationMs: number): string { |
| if (durationMs >= 1000) return `${(durationMs / 1000).toFixed(1)}s` |
| return `${durationMs}ms` |
| } |
|
|
| export function formatRate(rate: number): string { |
| if (rate <= 0) return "0%" |
| return `${(rate * 100).toFixed(1)}%` |
| } |
|
|
| export function formatTimestamp(timestamp: number): string { |
| return new Date(timestamp * 1000).toLocaleString() |
| } |
|
|
| export function formatShortTime(timestamp: number): string { |
| return new Date(timestamp * 1000).toLocaleTimeString(undefined, { |
| hour: "2-digit", |
| minute: "2-digit", |
| second: "2-digit", |
| }) |
| } |
|
|