Browse Source

zen: add usage graph

Frank 3 months ago
parent
commit
16cb77c094

+ 5 - 1
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=="],

+ 4 - 3
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:"

+ 16 - 0
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>
+  )
+}

+ 141 - 0
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;
+  }
+}

+ 419 - 0
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>
+  )
+}

+ 4 - 0
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 />

+ 6 - 0
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);

+ 3 - 2
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>