summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-11-18 00:45:14 -0500
committerFrank <[email protected]>2025-11-18 00:45:14 -0500
commit16cb77c094c6ac83a6b1fa0d03a5a6b2ac5d8648 (patch)
tree6b6988b9effe79a1b83ff7ceb6c54202c6dcfc88
parenta5564f730efec60317aced036b4dacdc0850bb3d (diff)
downloadopencode-16cb77c094c6ac83a6b1fa0d03a5a6b2ac5d8648.tar.gz
opencode-16cb77c094c6ac83a6b1fa0d03a5a6b2ac5d8648.zip
zen: add usage graph
-rw-r--r--bun.lock6
-rw-r--r--packages/console/app/package.json7
-rw-r--r--packages/console/app/src/component/icon.tsx16
-rw-r--r--packages/console/app/src/routes/workspace/[id]/graph-section.module.css141
-rw-r--r--packages/console/app/src/routes/workspace/[id]/graph-section.tsx419
-rw-r--r--packages/console/app/src/routes/workspace/[id]/index.tsx4
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.module.css6
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx5
8 files changed, 598 insertions, 6 deletions
diff --git a/bun.lock b/bun.lock
index 46538dc2b..f56d73a8a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "opencode",
@@ -29,6 +28,7 @@
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
+ "chart.js": "4.5.1",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:",
@@ -870,6 +870,8 @@
"@kobalte/utils": ["@kobalte/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
+ "@kurkle/color": ["@kurkle/[email protected]", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
+
"@mapbox/node-pre-gyp": ["@mapbox/[email protected]", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-llMXd39jtP0HpQLVI37Bf1m2ADlEb35GYSh1SDSLsBhR+5iCxiNGlT31yqbNtVHygHAtMy6dWFERpU2JgufhPg=="],
"@mdx-js/mdx": ["@mdx-js/[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
@@ -1726,6 +1728,8 @@
"character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+ "chart.js": ["[email protected]", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
+
"cheerio": ["[email protected]", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "htmlparser2": "^8.0.1", "parse5": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.0.0" } }, "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q=="],
"cheerio-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 9b1f7ff6e..9e8a13806 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -11,15 +11,16 @@
},
"dependencies": {
"@ibm/plex": "6.4.1",
+ "@jsx-email/render": "1.1.1",
+ "@kobalte/core": "catalog:",
+ "@openauthjs/openauth": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
- "@openauthjs/openauth": "catalog:",
- "@kobalte/core": "catalog:",
- "@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
+ "chart.js": "4.5.1",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:"
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx
index 0395cad52..0c352fcdb 100644
--- a/packages/console/app/src/component/icon.tsx
+++ b/packages/console/app/src/component/icon.tsx
@@ -212,3 +212,19 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
+
+export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 20 20" fill="none">
+ <path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
+ </svg>
+ )
+}
+
+export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 20 20" fill="none">
+ <path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
+ </svg>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.module.css b/packages/console/app/src/routes/workspace/[id]/graph-section.module.css
new file mode 100644
index 000000000..d31dad593
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/graph-section.module.css
@@ -0,0 +1,141 @@
+[data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ height: 400px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+[data-component="empty-state"] p {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+}
+
+[data-slot="filter-container"] {
+ margin-bottom: 0;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+[data-slot="month-picker"] {
+ display: flex;
+ align-items: center;
+ background-color: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ padding: 0;
+}
+
+[data-slot="month-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none !important;
+ color: var(--color-text);
+ cursor: pointer;
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--border-radius-xs);
+ transition: background-color 0.2s;
+ line-height: 1;
+}
+
+[data-slot="month-button"]:hover {
+ background-color: var(--color-bg-hover);
+}
+
+[data-slot="month-button"] svg {
+ display: block;
+ width: 16px;
+ height: 16px;
+ stroke-width: 2;
+}
+
+[data-slot="month-label"] {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-text);
+ line-height: 1.5;
+ min-width: 140px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+[data-slot="filter-container"] [data-component="dropdown"] [data-slot="trigger"] {
+ border: 1px solid var(--color-border);
+ background-color: var(--color-bg);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--border-radius-sm);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+
+ &:hover {
+ border-color: var(--color-accent);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
+ }
+}
+
+[data-slot="filter-container"] [data-component="dropdown"] [data-slot="chevron"] {
+ opacity: 0.6;
+}
+
+[data-slot="filter-container"] [data-component="dropdown"] [data-slot="dropdown"] {
+ min-width: 200px;
+ max-height: 300px;
+ overflow-y: auto;
+ padding: var(--space-1);
+}
+
+[data-slot="model-item"] {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ border: none !important;
+ background: none;
+ width: 100%;
+ text-align: left;
+ white-space: nowrap;
+}
+
+[data-slot="model-item"]:hover {
+ background: var(--color-bg-hover);
+}
+
+[data-slot="model-item"] span {
+ flex: 1;
+ user-select: none;
+}
+
+[data-slot="chart-container"] {
+ padding: var(--space-6);
+ background: var(--color-bg-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ height: 400px;
+}
+
+@media (max-width: 40rem) {
+ [data-slot="chart-container"] {
+ height: 300px;
+ padding: var(--space-4);
+ }
+
+ [data-component="empty-state"] {
+ height: 300px;
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
new file mode 100644
index 000000000..0fa298ffc
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx
@@ -0,0 +1,419 @@
+import { and, Database, eq, gte, inArray, isNull, lte, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
+import { createAsync, query, useParams } from "@solidjs/router"
+import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Dropdown } from "~/component/dropdown"
+import { IconChevronLeft, IconChevronRight } from "~/component/icon"
+import "./graph-section.module.css"
+import {
+ Chart,
+ BarController,
+ BarElement,
+ CategoryScale,
+ LinearScale,
+ Tooltip,
+ Legend,
+ type ChartConfiguration,
+} from "chart.js"
+
+Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
+
+async function getCosts(workspaceID: string, year: number, month: number) {
+ "use server"
+ return withActor(async () => {
+ const startDate = new Date(year, month, 1)
+ const endDate = new Date(year, month + 1, 0)
+
+ // First query: get usage data without joining keys
+ const usageData = await Database.use((tx) =>
+ tx
+ .select({
+ date: sql<string>`DATE(${UsageTable.timeCreated})`,
+ model: UsageTable.model,
+ totalCost: sql<number>`SUM(${UsageTable.cost})`,
+ keyId: UsageTable.keyID,
+ })
+ .from(UsageTable)
+ .where(
+ and(
+ eq(UsageTable.workspaceID, workspaceID),
+ gte(UsageTable.timeCreated, startDate),
+ lte(UsageTable.timeCreated, endDate),
+ ),
+ )
+ .groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID),
+ )
+
+ // Get unique key IDs from usage
+ const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
+
+ // Second query: get all existing keys plus any keys from usage
+ const keysData = await Database.use((tx) =>
+ tx
+ .select({
+ keyId: KeyTable.id,
+ keyName: KeyTable.name,
+ userEmail: AuthTable.subject,
+ timeDeleted: KeyTable.timeDeleted,
+ })
+ .from(KeyTable)
+ .innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
+ .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
+ .where(
+ and(
+ eq(KeyTable.workspaceID, workspaceID),
+ usageKeyIds.size > 0
+ ? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
+ : isNull(KeyTable.timeDeleted),
+ ),
+ )
+ .orderBy(AuthTable.subject, KeyTable.name),
+ )
+
+ return {
+ usage: usageData,
+ keys: keysData.map((key) => ({
+ id: key.keyId,
+ displayName:
+ key.timeDeleted !== null
+ ? `${key.userEmail} - ${key.keyName} (deleted)`
+ : `${key.userEmail} - ${key.keyName}`,
+ })),
+ }
+ }, workspaceID)
+}
+
+const queryCosts = query(getCosts, "costs.get")
+
+const MODEL_COLORS: Record<string, string> = {
+ "claude-sonnet-4-5": "#D4745C",
+ "claude-sonnet-4": "#E8B4A4",
+ "claude-opus-4": "#C8A098",
+ "claude-haiku-4-5": "#F0D8D0",
+ "claude-3-5-haiku": "#F8E8E0",
+ "gpt-5.1": "#4A90E2",
+ "gpt-5.1-codex": "#6BA8F0",
+ "gpt-5": "#7DB8F8",
+ "gpt-5-codex": "#9FCAFF",
+ "gpt-5-nano": "#B8D8FF",
+ "grok-code": "#8B5CF6",
+ "big-pickle": "#10B981",
+ "kimi-k2": "#F59E0B",
+ "qwen3-coder": "#EC4899",
+ "glm-4.6": "#14B8A6",
+}
+
+function getModelColor(model: string): string {
+ if (MODEL_COLORS[model]) return MODEL_COLORS[model]
+
+ const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
+ const hue = Math.abs(hash) % 360
+ return `hsl(${hue}, 50%, 65%)`
+}
+
+function formatDateLabel(dateStr: string): string {
+ const date = new Date()
+ const [y, m, d] = dateStr.split("-").map(Number)
+ date.setFullYear(y)
+ date.setMonth(m - 1)
+ date.setDate(d)
+ date.setHours(0, 0, 0, 0)
+ const month = date.toLocaleDateString("en-US", { month: "short" })
+ const day = date.getUTCDate().toString().padStart(2, "0")
+ return `${month} ${day}`
+}
+
+function addOpacityToColor(color: string, opacity: number): string {
+ if (color.startsWith("#")) {
+ const r = parseInt(color.slice(1, 3), 16)
+ const g = parseInt(color.slice(3, 5), 16)
+ const b = parseInt(color.slice(5, 7), 16)
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
+ }
+ if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
+ return color
+}
+
+export function GraphSection() {
+ let canvasRef: HTMLCanvasElement | undefined
+ let chartInstance: Chart | undefined
+ const params = useParams()
+ const now = new Date()
+ const [store, setStore] = createStore({
+ data: null as Awaited<ReturnType<typeof getCosts>> | null,
+ year: now.getFullYear(),
+ month: now.getMonth(),
+ key: null as string | null,
+ model: null as string | null,
+ modelDropdownOpen: false,
+ keyDropdownOpen: false,
+ })
+ const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
+
+ const onPreviousMonth = async () => {
+ const month = store.month === 0 ? 11 : store.month - 1
+ const year = store.month === 0 ? store.year - 1 : store.year
+ const data = await getCosts(params.id!, year, month)
+ setStore({ month, year, data })
+ }
+
+ const onNextMonth = async () => {
+ const month = store.month === 11 ? 0 : store.month + 1
+ const year = store.month === 11 ? store.year + 1 : store.year
+ setStore({ month, year, data: await getCosts(params.id!, year, month) })
+ }
+
+ const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
+
+ const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
+
+ const getData = createMemo(() => store.data ?? initialData())
+
+ const getModels = createMemo(() => {
+ const data = getData()
+ if (!data?.usage) return []
+ return Array.from(new Set(data.usage.map((row) => row.model))).sort()
+ })
+
+ const getDates = createMemo(() => {
+ const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
+ return Array.from({ length: daysInMonth }, (_, i) => {
+ const date = new Date(store.year, store.month, i + 1)
+ return date.toISOString().split("T")[0]
+ })
+ })
+
+ const getKeyName = (keyID: string | null): string => {
+ if (!keyID || !store.data?.keys) return "All Keys"
+ const found = store.data.keys.find((k) => k.id === keyID)
+ return found?.displayName ?? "All Keys"
+ }
+
+ const formatMonthYear = () =>
+ new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
+
+ const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
+
+ const chartConfig = createMemo((): ChartConfiguration | null => {
+ const data = getData()
+ const dates = getDates()
+ if (!data?.usage?.length) return null
+
+ const filteredUsageResults = store.key ? data.usage.filter((row) => row.keyId === store.key) : data.usage
+
+ const dailyData = new Map<string, Map<string, number>>()
+ for (const dateKey of dates) dailyData.set(dateKey, new Map())
+
+ for (const row of filteredUsageResults) {
+ const dayMap = dailyData.get(row.date)
+ if (dayMap) {
+ const existing = dayMap.get(row.model) || 0
+ dayMap.set(row.model, existing + row.totalCost)
+ }
+ }
+
+ const filteredModels = store.model === null ? getModels() : [store.model]
+
+ const datasets = filteredModels.map((model) => {
+ const color = getModelColor(model)
+ return {
+ label: model,
+ data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100000000),
+ backgroundColor: color,
+ hoverBackgroundColor: color,
+ borderWidth: 0,
+ }
+ })
+
+ return {
+ type: "bar",
+ data: {
+ labels: dates.map(formatDateLabel),
+ datasets,
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: {
+ stacked: true,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ maxRotation: 0,
+ autoSkipPadding: 20,
+ color: "rgba(255, 255, 255, 0.5)",
+ font: {
+ family: "monospace",
+ size: 11,
+ },
+ },
+ },
+ y: {
+ stacked: true,
+ beginAtZero: true,
+ grid: {
+ color: "rgba(255, 255, 255, 0.1)",
+ },
+ ticks: {
+ color: "rgba(255, 255, 255, 0.5)",
+ font: {
+ family: "monospace",
+ size: 11,
+ },
+ callback: (value) => {
+ const num = Number(value)
+ return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
+ },
+ },
+ },
+ },
+ plugins: {
+ tooltip: {
+ mode: "index",
+ intersect: false,
+ backgroundColor: "rgba(0, 0, 0, 0.9)",
+ titleColor: "rgba(255, 255, 255, 0.9)",
+ bodyColor: "rgba(255, 255, 255, 0.8)",
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ borderWidth: 1,
+ padding: 12,
+ displayColors: true,
+ callbacks: {
+ label: (context) => {
+ const value = context.parsed.y
+ if (!value || value === 0) return
+ return `${context.dataset.label}: $${value.toFixed(2)}`
+ },
+ },
+ },
+ legend: {
+ display: true,
+ position: "bottom",
+ labels: {
+ color: "rgba(255, 255, 255, 0.7)",
+ font: {
+ size: 12,
+ },
+ padding: 16,
+ boxWidth: 16,
+ boxHeight: 16,
+ usePointStyle: false,
+ },
+ onHover: (event, legendItem, legend) => {
+ const chart = legend.chart
+ chart.data.datasets?.forEach((dataset, i) => {
+ const meta = chart.getDatasetMeta(i)
+ const baseColor = getModelColor(dataset.label || "")
+ const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
+ meta.data.forEach((bar: any) => {
+ bar.options.backgroundColor = color
+ })
+ })
+ chart.update("none")
+ },
+ onLeave: (event, legendItem, legend) => {
+ const chart = legend.chart
+ chart.data.datasets?.forEach((dataset, i) => {
+ const meta = chart.getDatasetMeta(i)
+ const baseColor = getModelColor(dataset.label || "")
+ meta.data.forEach((bar: any) => {
+ bar.options.backgroundColor = baseColor
+ })
+ })
+ chart.update("none")
+ },
+ },
+ },
+ },
+ }
+ })
+
+ createEffect(() => {
+ const config = chartConfig()
+ if (!config || !canvasRef) return
+
+ if (chartInstance) chartInstance.destroy()
+ chartInstance = new Chart(canvasRef, config)
+ })
+
+ onCleanup(() => chartInstance?.destroy())
+
+ return (
+ <section>
+ <div data-slot="section-title">
+ <h2>Cost</h2>
+ <p>Usage costs broken down by model.</p>
+ </div>
+
+ <Show when={getData()}>
+ <div data-slot="filter-container">
+ <div data-slot="month-picker">
+ <button data-slot="month-button" onClick={onPreviousMonth}>
+ <IconChevronLeft />
+ </button>
+ <span data-slot="month-label">{formatMonthYear()}</span>
+ <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
+ <IconChevronRight />
+ </button>
+ </div>
+ <Dropdown
+ trigger={store.model === null ? "All Models" : store.model}
+ open={store.modelDropdownOpen}
+ onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
+ >
+ <>
+ <button data-slot="model-item" onClick={() => onSelectModel(null)}>
+ <span>All Models</span>
+ </button>
+ <For each={getModels()}>
+ {(model) => (
+ <button data-slot="model-item" onClick={() => onSelectModel(model)}>
+ <span>{model}</span>
+ </button>
+ )}
+ </For>
+ </>
+ </Dropdown>
+ <Dropdown
+ trigger={getKeyName(store.key)}
+ open={store.keyDropdownOpen}
+ onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
+ >
+ <>
+ <button data-slot="model-item" onClick={() => onSelectKey(null)}>
+ <span>All Keys</span>
+ </button>
+ <For each={getData()?.keys || []}>
+ {(key) => (
+ <button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
+ <span>{key.displayName}</span>
+ </button>
+ )}
+ </For>
+ </>
+ </Dropdown>
+ </div>
+ </Show>
+
+ <Show
+ when={chartConfig()}
+ fallback={
+ <div data-component="empty-state">
+ <p>No usage data available for the selected period.</p>
+ </div>
+ }
+ >
+ <div data-slot="chart-container">
+ <canvas ref={canvasRef} />
+ </div>
+ </Show>
+ </section>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx
index acf29d299..e25e09645 100644
--- a/packages/console/app/src/routes/workspace/[id]/index.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/index.tsx
@@ -5,6 +5,7 @@ import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
+import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
@@ -66,6 +67,9 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
+ <Show when={userInfo()?.isAdmin}>
+ <GraphSection />
+ </Show>
<ModelSection />
<Show when={userInfo()?.isAdmin}>
<ProviderSection />
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
index 2bd331bd9..31092a7e7 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
@@ -81,6 +81,12 @@
cursor: pointer;
transition: all 0.15s ease;
+ svg {
+ width: 16px;
+ height: 16px;
+ stroke-width: 2;
+ }
+
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
index 6b3d1af60..b97bc53d7 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -3,6 +3,7 @@ import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
+import { IconChevronLeft, IconChevronRight } from "~/component/icon"
import "./usage-section.module.css"
import { createStore } from "solid-js/store"
@@ -92,10 +93,10 @@ export function UsageSection() {
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
- ←
+ <IconChevronLeft />
</button>
<button disabled={!canGoNext()} onClick={goNext}>
- →
+ <IconChevronRight />
</button>
</div>
</Show>