From ed3bb3ea8f18a3d2818eb17d77e2caad40eb1dee Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 11 Mar 2026 00:39:56 -0400 Subject: zen: add usage section --- packages/console/app/src/i18n/ar.ts | 1 + packages/console/app/src/i18n/br.ts | 1 + packages/console/app/src/i18n/da.ts | 1 + packages/console/app/src/i18n/de.ts | 1 + packages/console/app/src/i18n/en.ts | 1 + packages/console/app/src/i18n/es.ts | 1 + packages/console/app/src/i18n/fr.ts | 1 + packages/console/app/src/i18n/it.ts | 1 + packages/console/app/src/i18n/ja.ts | 1 + packages/console/app/src/i18n/ko.ts | 1 + packages/console/app/src/i18n/no.ts | 1 + packages/console/app/src/i18n/pl.ts | 1 + packages/console/app/src/i18n/ru.ts | 1 + packages/console/app/src/i18n/th.ts | 1 + packages/console/app/src/i18n/tr.ts | 1 + packages/console/app/src/i18n/zh.ts | 1 + packages/console/app/src/i18n/zht.ts | 1 + packages/console/app/src/routes/workspace/[id].tsx | 6 + .../routes/workspace/[id]/graph-section.module.css | 145 ------ .../src/routes/workspace/[id]/graph-section.tsx | 515 --------------------- .../app/src/routes/workspace/[id]/index.tsx | 6 - .../routes/workspace/[id]/usage-section.module.css | 185 -------- .../src/routes/workspace/[id]/usage-section.tsx | 217 --------- .../workspace/[id]/usage/graph-section.module.css | 145 ++++++ .../routes/workspace/[id]/usage/graph-section.tsx | 515 +++++++++++++++++++++ .../app/src/routes/workspace/[id]/usage/index.tsx | 21 + .../workspace/[id]/usage/usage-section.module.css | 185 ++++++++ .../routes/workspace/[id]/usage/usage-section.tsx | 217 +++++++++ 28 files changed, 1106 insertions(+), 1068 deletions(-) delete mode 100644 packages/console/app/src/routes/workspace/[id]/graph-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/[id]/graph-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/[id]/usage-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/[id]/usage-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/usage/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx (limited to 'packages') diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 86d51226a..a17530920 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -411,6 +411,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "سيتم خصم المبلغ من بطاقتك عند تفعيل اشتراكك", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "الاستخدام", "workspace.nav.apiKeys": "مفاتيح API", "workspace.nav.members": "الأعضاء", "workspace.nav.billing": "الفوترة", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index f14a69c85..f395db9f4 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -418,6 +418,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Seu cartão será cobrado quando sua assinatura for ativada", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Uso", "workspace.nav.apiKeys": "Chaves de API", "workspace.nav.members": "Membros", "workspace.nav.billing": "Faturamento", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 775f029fb..6e2af12af 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -414,6 +414,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Dit kort vil blive debiteret, når dit abonnement er aktiveret", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Brug", "workspace.nav.apiKeys": "API-nøgler", "workspace.nav.members": "Medlemmer", "workspace.nav.billing": "Fakturering", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 2d9be14ff..87094a28f 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -417,6 +417,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Deine Karte wird belastet, sobald dein Abonnement aktiviert ist", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Nutzung", "workspace.nav.apiKeys": "API Keys", "workspace.nav.members": "Mitglieder", "workspace.nav.billing": "Abrechnung", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 02d6126c4..1f1773dd4 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -411,6 +411,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Your card will be charged when your subscription is activated", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Usage", "workspace.nav.apiKeys": "API Keys", "workspace.nav.members": "Members", "workspace.nav.billing": "Billing", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 25b8d37d7..19a9842d5 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -419,6 +419,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Tu tarjeta se cargará cuando tu suscripción se active", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Uso", "workspace.nav.apiKeys": "Claves API", "workspace.nav.members": "Miembros", "workspace.nav.billing": "Facturación", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index ddf33c0ec..cf6d5e95b 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -419,6 +419,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Votre carte sera débitée lorsque votre abonnement sera activé", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Utilisation", "workspace.nav.apiKeys": "Clés API", "workspace.nav.members": "Membres", "workspace.nav.billing": "Facturation", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 770efde45..b967f078c 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -417,6 +417,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "La tua carta verrà addebitata quando il tuo abbonamento sarà attivato", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Utilizzo", "workspace.nav.apiKeys": "Chiavi API", "workspace.nav.members": "Membri", "workspace.nav.billing": "Fatturazione", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index f2786ba8d..759d5e7c7 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -416,6 +416,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "サブスクリプションが有効化された時点でカードに請求されます", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "利用", "workspace.nav.apiKeys": "APIキー", "workspace.nav.members": "メンバー", "workspace.nav.billing": "請求", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 169b56c0a..9a5237f33 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -410,6 +410,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "구독이 활성화되면 카드에 청구됩니다", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "사용량", "workspace.nav.apiKeys": "API 키", "workspace.nav.members": "멤버", "workspace.nav.billing": "결제", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 0b6e76e0c..fa44e535e 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -415,6 +415,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Kortet ditt vil bli belastet når abonnementet aktiveres", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Bruk", "workspace.nav.apiKeys": "API-nøkler", "workspace.nav.members": "Medlemmer", "workspace.nav.billing": "Fakturering", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index b46280ae1..7c7300ab9 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -416,6 +416,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Twoja karta zostanie obciążona po aktywacji subskrypcji", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Użycie", "workspace.nav.apiKeys": "Klucze API", "workspace.nav.members": "Członkowie", "workspace.nav.billing": "Rozliczenia", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 801c8fc7d..77e36bc99 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -421,6 +421,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "С вашей карты будет списана оплата при активации подписки", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Использование", "workspace.nav.apiKeys": "API Ключи", "workspace.nav.members": "Участники", "workspace.nav.billing": "Оплата", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index d9d7d03d1..9d1e92fa8 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -413,6 +413,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "บัตรของคุณจะถูกเรียกเก็บเงินเมื่อการสมัครสมาชิกของคุณถูกเปิดใช้งาน", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "การใช้งาน", "workspace.nav.apiKeys": "API Keys", "workspace.nav.members": "สมาชิก", "workspace.nav.billing": "การเรียกเก็บเงิน", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index e28afe2b0..84ad9a7e3 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -418,6 +418,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "Aboneliğiniz aktive edildiğinde kartınızdan ödeme alınacaktır", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "Kullanım", "workspace.nav.apiKeys": "API Anahtarları", "workspace.nav.members": "Üyeler", "workspace.nav.billing": "Faturalandırma", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index e12fe7749..a3527ae3e 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -396,6 +396,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "您的卡将在订阅激活时扣费", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "使用量", "workspace.nav.apiKeys": "API 密钥", "workspace.nav.members": "成员", "workspace.nav.billing": "计费", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index b45f87156..1167d5a6c 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -397,6 +397,7 @@ export const dict = { "black.subscribe.success.chargeNotice": "你的卡片將在訂閱啟用時扣款", "workspace.nav.zen": "Zen", + "workspace.nav.usage": "使用量", "workspace.nav.apiKeys": "API 金鑰", "workspace.nav.members": "成員", "workspace.nav.billing": "帳務", diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index fde074e5e..a36368fd0 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -19,6 +19,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) { {i18n.t("workspace.nav.zen")} + + {i18n.t("workspace.nav.usage")} + {i18n.t("workspace.nav.apiKeys")} @@ -41,6 +44,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) { {i18n.t("workspace.nav.zen")} + + {i18n.t("workspace.nav.usage")} + {i18n.t("workspace.nav.apiKeys")} 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 deleted file mode 100644 index 24b85be74..000000000 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.module.css +++ /dev/null @@ -1,145 +0,0 @@ -.root { - [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; - - 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-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="chevron"] { - opacity: 0.6; - } - - [data-slot="dropdown"] { - min-width: 200px; - max-height: 300px; - overflow-y: auto; - padding: var(--space-1); - } - } - } - - [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; - - &:hover { - background-color: var(--color-bg-hover); - } - - 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="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; - - &:hover { - background: var(--color-bg-hover); - } - - 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 deleted file mode 100644 index bb4b4f4cf..000000000 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx +++ /dev/null @@ -1,515 +0,0 @@ -import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } 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 { 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 styles from "./graph-section.module.css" -import { - Chart, - BarController, - BarElement, - CategoryScale, - LinearScale, - Tooltip, - Legend, - type ChartConfiguration, -} from "chart.js" -import { useI18n } from "~/context/i18n" - -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, 1) - const usageData = await Database.use((tx) => - tx - .select({ - date: sql`DATE(${UsageTable.timeCreated})`, - model: UsageTable.model, - totalCost: sum(UsageTable.cost), - keyId: UsageTable.keyID, - plan: sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, - }) - .from(UsageTable) - .where( - and( - eq(UsageTable.workspaceID, workspaceID), - gte(UsageTable.timeCreated, startDate), - lt(UsageTable.timeCreated, endDate), - ), - ) - .groupBy( - sql`DATE(${UsageTable.timeCreated})`, - UsageTable.model, - UsageTable.keyID, - sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, - ) - .then((x) => - x.map((r) => ({ - ...r, - totalCost: r.totalCost ? parseInt(r.totalCost) : 0, - plan: r.plan as "sub" | "lite" | "byok" | null, - })), - ), - ) - - // 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.userEmail} - ${key.keyName}`, - deleted: key.timeDeleted !== null, - })), - } - }, workspaceID) -} - -const MODEL_COLORS: Record = { - "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(undefined, { 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 i18n = useI18n() - const now = new Date() - const [store, setStore] = createStore({ - data: null as Awaited> | null, - year: now.getFullYear(), - month: now.getMonth(), - key: null as string | null, - model: null as string | null, - modelDropdownOpen: false, - keyDropdownOpen: false, - colorScheme: "light" as "light" | "dark", - }) - const onPreviousMonth = async () => { - const month = store.month === 0 ? 11 : store.month - 1 - const year = store.month === 0 ? store.year - 1 : store.year - setStore({ month, year }) - } - - 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 }) - } - - const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false }) - - const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false }) - - const getModels = createMemo(() => { - if (!store.data?.usage) return [] - return Array.from(new Set(store.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 i18n.t("workspace.cost.allKeys") - const found = store.data.keys.find((k) => k.id === keyID) - if (!found) return i18n.t("workspace.cost.allKeys") - return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName - } - - const formatMonthYear = () => - new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" }) - - const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth() - - const chartConfig = createMemo((): ChartConfiguration | null => { - const data = store.data - const dates = getDates() - if (!data?.usage?.length) return null - - store.colorScheme - const styles = getComputedStyle(document.documentElement) - const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim() - const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim() - const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim() - const colorText = styles.getPropertyValue("--color-text").trim() - const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim() - const colorBorder = styles.getPropertyValue("--color-border").trim() - const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})` - const liteSuffix = " (go)" - - const dailyDataRegular = new Map>() - const dailyDataSub = new Map>() - const dailyDataLite = new Map>() - for (const dateKey of dates) { - dailyDataRegular.set(dateKey, new Map()) - dailyDataSub.set(dateKey, new Map()) - dailyDataLite.set(dateKey, new Map()) - } - - data.usage - .filter((row) => (store.key ? row.keyId === store.key : true)) - .forEach((row) => { - const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular - const dayMap = targetMap.get(row.date) - if (!dayMap) return - dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost) - }) - - const filteredModels = store.model === null ? getModels() : [store.model] - - // Create datasets: regular first, then subscription, then lite (with visual distinction via opacity) - const datasets = [ - ...filteredModels - .filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0)) - .map((model) => { - const color = getModelColor(model) - return { - label: model, - data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000), - backgroundColor: color, - hoverBackgroundColor: color, - borderWidth: 0, - stack: "usage", - } - }), - ...filteredModels - .filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0)) - .map((model) => { - const color = getModelColor(model) - return { - label: `${model}${subSuffix}`, - data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000), - backgroundColor: addOpacityToColor(color, 0.5), - hoverBackgroundColor: addOpacityToColor(color, 0.7), - borderWidth: 1, - borderColor: color, - stack: "subscription", - } - }), - ...filteredModels - .filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0)) - .map((model) => { - const color = getModelColor(model) - return { - label: `${model}${liteSuffix}`, - data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000), - backgroundColor: addOpacityToColor(color, 0.35), - hoverBackgroundColor: addOpacityToColor(color, 0.55), - borderWidth: 1, - borderColor: addOpacityToColor(color, 0.7), - borderDash: [4, 2], - stack: "lite", - } - }), - ] - - 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: colorTextMuted, - font: { - family: "monospace", - size: 11, - }, - }, - }, - y: { - stacked: true, - beginAtZero: true, - grid: { - color: colorBorderMuted, - }, - ticks: { - color: colorTextMuted, - 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: colorBgElevated, - titleColor: colorText, - bodyColor: colorTextSecondary, - borderColor: colorBorder, - borderWidth: 1, - padding: 12, - displayColors: true, - filter: (item) => (item.parsed.y ?? 0) > 0, - callbacks: { - label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`, - }, - }, - legend: { - display: true, - position: "bottom", - labels: { - color: colorTextSecondary, - 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 label = dataset.label || "" - const isSub = label.endsWith(subSuffix) - const isLite = label.endsWith(liteSuffix) - const model = isSub - ? label.slice(0, -subSuffix.length) - : isLite - ? label.slice(0, -liteSuffix.length) - : label - const baseColor = getModelColor(model) - const originalColor = isSub - ? addOpacityToColor(baseColor, 0.5) - : isLite - ? addOpacityToColor(baseColor, 0.35) - : baseColor - const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15) - 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 label = dataset.label || "" - const isSub = label.endsWith(subSuffix) - const isLite = label.endsWith(liteSuffix) - const model = isSub - ? label.slice(0, -subSuffix.length) - : isLite - ? label.slice(0, -liteSuffix.length) - : label - const baseColor = getModelColor(model) - const color = isSub - ? addOpacityToColor(baseColor, 0.5) - : isLite - ? addOpacityToColor(baseColor, 0.35) - : baseColor - meta.data.forEach((bar: any) => { - bar.options.backgroundColor = color - }) - }) - chart.update("none") - }, - }, - }, - }, - } - }) - - createEffect(async () => { - const data = await getCosts(params.id!, store.year, store.month) - setStore({ data }) - }) - - createEffect(() => { - const config = chartConfig() - if (!config || !canvasRef) return - - if (chartInstance) chartInstance.destroy() - chartInstance = new Chart(canvasRef, config) - - onCleanup(() => chartInstance?.destroy()) - }) - - createEffect(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" }) - - const handleColorSchemeChange = (e: MediaQueryListEvent) => { - setStore({ colorScheme: e.matches ? "dark" : "light" }) - } - - mediaQuery.addEventListener("change", handleColorSchemeChange) - onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange)) - }) - - return ( -
-
-

{i18n.t("workspace.cost.title")}

-

{i18n.t("workspace.cost.subtitle")}

-
- -
-
- - {formatMonthYear()} - -
- setStore({ modelDropdownOpen: open })} - > - <> - - - {(model) => ( - - )} - - - - setStore({ keyDropdownOpen: open })} - > - <> - - - {(key) => ( - - )} - - - -
- - -

{i18n.t("workspace.cost.empty")}

- - } - > -
- -
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx index c91cfd2bc..ca3c2bc0c 100644 --- a/packages/console/app/src/routes/workspace/[id]/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -2,10 +2,8 @@ import { Match, Show, Switch, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router" 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" import { useI18n } from "~/context/i18n" @@ -73,14 +71,10 @@ export default function () {
- - - -
) 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 deleted file mode 100644 index 00232de88..000000000 --- a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css +++ /dev/null @@ -1,185 +0,0 @@ -.root { - /* Empty state */ - [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); - - p { - font-size: var(--font-size-sm); - color: var(--color-text-muted); - } - } - - /* Table container */ - [data-slot="usage-table"] { - overflow-x: auto; - } - - /* Table element */ - [data-slot="usage-table-element"] { - width: 100%; - border-collapse: collapse; - font-size: var(--font-size-sm); - - thead { - border-bottom: 1px solid var(--color-border); - } - - th { - padding: var(--space-3) var(--space-4); - text-align: left; - font-weight: normal; - color: var(--color-text-muted); - text-transform: uppercase; - } - - td { - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--color-border-muted); - color: var(--color-text-muted); - font-family: var(--font-mono); - - &[data-slot="usage-date"] { - color: var(--color-text-muted); - } - - &[data-slot="usage-model"] { - font-family: var(--font-sans); - color: var(--color-text-secondary); - max-width: 200px; - word-break: break-word; - } - - &[data-slot="usage-cost"] { - color: var(--color-text-muted); - } - - [data-slot="tokens-with-breakdown"] { - position: relative; - display: flex; - align-items: center; - gap: var(--space-2); - } - - [data-slot="breakdown-button"] { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - background: transparent; - border: none; - color: var(--color-text-muted); - cursor: pointer; - transition: color 0.15s ease; - - &:hover { - color: var(--color-text); - } - - svg { - width: 16px; - height: 16px; - } - } - - [data-slot="breakdown-popup"] { - position: absolute; - left: 0; - top: 100%; - margin-top: var(--space-2); - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-sm); - padding: var(--space-2); - z-index: 10; - min-width: 180px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - font-size: var(--font-size-xs); - - @media (prefers-color-scheme: dark) { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - } - } - } - - tbody tr:last-child td { - border-bottom: none; - } - } - - /* Pagination */ - [data-slot="pagination"] { - display: flex; - justify-content: flex-end; - gap: var(--space-2); - padding: var(--space-4) 0; - border-top: 1px solid var(--color-border-muted); - margin-top: var(--space-2); - - button { - padding: var(--space-2) var(--space-4); - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-sm); - color: var(--color-text); - font-size: var(--font-size-sm); - 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); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - } - - /* Mobile responsive */ - @media (max-width: 40rem) { - [data-slot="usage-table-element"] { - th, - td { - padding: var(--space-2) var(--space-3); - font-size: var(--font-size-xs); - } - - /* Hide Model column on mobile */ - th:nth-child(2), - td:nth-child(2) { - display: none; - } - } - } - - /* Breakdown popup content */ - [data-slot="breakdown-row"] { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-4); - padding: var(--space-1) 0; - } - - [data-slot="breakdown-label"] { - color: var(--color-text-muted); - font-size: var(--font-size-xs); - } - - [data-slot="breakdown-value"] { - color: var(--color-text); - font-weight: 500; - font-size: var(--font-size-xs); - } -} diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx deleted file mode 100644 index a20a5bf0d..000000000 --- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { Billing } from "@opencode-ai/console-core/billing.js" -import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js" -import { formatDateUTC, formatDateForTable } from "../common" -import { withActor } from "~/context/auth.withActor" -import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon" -import styles from "./usage-section.module.css" -import { createStore } from "solid-js/store" -import { useI18n } from "~/context/i18n" - -const PAGE_SIZE = 50 - -async function getUsageInfo(workspaceID: string, page: number) { - "use server" - return withActor(async () => { - return await Billing.usages(page, PAGE_SIZE) - }, workspaceID) -} - -const queryUsageInfo = query(getUsageInfo, "usage.list") - -export function UsageSection() { - const params = useParams() - const i18n = useI18n() - const usage = createAsync(() => queryUsageInfo(params.id!, 0)) - const [store, setStore] = createStore({ page: 0, usage: [] as Awaited> }) - const [openBreakdownId, setOpenBreakdownId] = createSignal(null) - - createEffect(() => { - setStore({ usage: usage() }) - }, [usage]) - - createEffect(() => { - if (!openBreakdownId()) return - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as HTMLElement - if (!target.closest('[data-slot="tokens-with-breakdown"]')) { - setOpenBreakdownId(null) - } - } - - document.addEventListener("click", handleClickOutside) - return () => document.removeEventListener("click", handleClickOutside) - }) - - const hasResults = createMemo(() => store.usage && store.usage.length > 0) - const canGoPrev = createMemo(() => store.page > 0) - const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) - - const calculateTotalInputTokens = (u: Awaited>[0]) => { - return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0) - } - - const calculateTotalOutputTokens = (u: Awaited>[0]) => { - return u.outputTokens + (u.reasoningTokens ?? 0) - } - - const goPrev = async () => { - const usage = await getUsageInfo(params.id!, store.page - 1) - setStore({ - page: store.page - 1, - usage, - }) - } - const goNext = async () => { - const usage = await getUsageInfo(params.id!, store.page + 1) - setStore({ - page: store.page + 1, - usage, - }) - } - - return ( -
-
-

{i18n.t("workspace.usage.title")}

-

{i18n.t("workspace.usage.subtitle")}

-
-
- -

{i18n.t("workspace.usage.empty")}

-
- } - > - - - - - - - - - - - - - - {(usage, index) => { - const date = createMemo(() => new Date(usage.timeCreated)) - const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage)) - const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage)) - const inputBreakdownId = `input-breakdown-${index()}` - const outputBreakdownId = `output-breakdown-${index()}` - const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId) - const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId) - const isClaude = usage.model.toLowerCase().includes("claude") - return ( - - - - - - - - - ) - }} - - -
{i18n.t("workspace.usage.table.date")}{i18n.t("workspace.usage.table.model")}{i18n.t("workspace.usage.table.input")}{i18n.t("workspace.usage.table.output")}{i18n.t("workspace.usage.table.cost")}{i18n.t("workspace.usage.table.session")}
- {formatDateForTable(date())} - {usage.model} -
e.stopPropagation()}> - - setOpenBreakdownId(null)}>{totalInputTokens()} - -
e.stopPropagation()}> -
- {i18n.t("workspace.usage.breakdown.input")} - {usage.inputTokens} -
-
- {i18n.t("workspace.usage.breakdown.cacheRead")} - {usage.cacheReadTokens ?? 0} -
- -
- - {i18n.t("workspace.usage.breakdown.cacheWrite")} - - {usage.cacheWrite5mTokens ?? 0} -
-
-
-
-
-
-
e.stopPropagation()}> - - setOpenBreakdownId(null)}>{totalOutputTokens()} - -
e.stopPropagation()}> -
- {i18n.t("workspace.usage.breakdown.output")} - {usage.outputTokens} -
-
- {i18n.t("workspace.usage.breakdown.reasoning")} - {usage.reasoningTokens ?? 0} -
-
-
-
-
- ${((usage.cost ?? 0) / 100000000).toFixed(4)}}> - - {i18n.t("workspace.usage.subscription", { - amount: ((usage.cost ?? 0) / 100000000).toFixed(4), - })} - - - {i18n.t("workspace.usage.lite", { - amount: ((usage.cost ?? 0) / 100000000).toFixed(4), - })} - - - {i18n.t("workspace.usage.byok", { - amount: ((usage.cost ?? 0) / 100000000).toFixed(4), - })} - - - {usage.sessionID?.slice(-8) ?? "-"}
- -
- - -
-
- - -
- ) -} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css new file mode 100644 index 000000000..24b85be74 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.module.css @@ -0,0 +1,145 @@ +.root { + [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; + + 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-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="chevron"] { + opacity: 0.6; + } + + [data-slot="dropdown"] { + min-width: 200px; + max-height: 300px; + overflow-y: auto; + padding: var(--space-1); + } + } + } + + [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; + + &:hover { + background-color: var(--color-bg-hover); + } + + 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="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; + + &:hover { + background: var(--color-bg-hover); + } + + 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]/usage/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx new file mode 100644 index 000000000..bb4b4f4cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/graph-section.tsx @@ -0,0 +1,515 @@ +import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } 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 { 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 styles from "./graph-section.module.css" +import { + Chart, + BarController, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + Legend, + type ChartConfiguration, +} from "chart.js" +import { useI18n } from "~/context/i18n" + +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, 1) + const usageData = await Database.use((tx) => + tx + .select({ + date: sql`DATE(${UsageTable.timeCreated})`, + model: UsageTable.model, + totalCost: sum(UsageTable.cost), + keyId: UsageTable.keyID, + plan: sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, + }) + .from(UsageTable) + .where( + and( + eq(UsageTable.workspaceID, workspaceID), + gte(UsageTable.timeCreated, startDate), + lt(UsageTable.timeCreated, endDate), + ), + ) + .groupBy( + sql`DATE(${UsageTable.timeCreated})`, + UsageTable.model, + UsageTable.keyID, + sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`, + ) + .then((x) => + x.map((r) => ({ + ...r, + totalCost: r.totalCost ? parseInt(r.totalCost) : 0, + plan: r.plan as "sub" | "lite" | "byok" | null, + })), + ), + ) + + // 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.userEmail} - ${key.keyName}`, + deleted: key.timeDeleted !== null, + })), + } + }, workspaceID) +} + +const MODEL_COLORS: Record = { + "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(undefined, { 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 i18n = useI18n() + const now = new Date() + const [store, setStore] = createStore({ + data: null as Awaited> | null, + year: now.getFullYear(), + month: now.getMonth(), + key: null as string | null, + model: null as string | null, + modelDropdownOpen: false, + keyDropdownOpen: false, + colorScheme: "light" as "light" | "dark", + }) + const onPreviousMonth = async () => { + const month = store.month === 0 ? 11 : store.month - 1 + const year = store.month === 0 ? store.year - 1 : store.year + setStore({ month, year }) + } + + 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 }) + } + + const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false }) + + const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false }) + + const getModels = createMemo(() => { + if (!store.data?.usage) return [] + return Array.from(new Set(store.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 i18n.t("workspace.cost.allKeys") + const found = store.data.keys.find((k) => k.id === keyID) + if (!found) return i18n.t("workspace.cost.allKeys") + return found.deleted ? `${found.displayName} ${i18n.t("workspace.cost.deletedSuffix")}` : found.displayName + } + + const formatMonthYear = () => + new Date(store.year, store.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" }) + + const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth() + + const chartConfig = createMemo((): ChartConfiguration | null => { + const data = store.data + const dates = getDates() + if (!data?.usage?.length) return null + + store.colorScheme + const styles = getComputedStyle(document.documentElement) + const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim() + const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim() + const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim() + const colorText = styles.getPropertyValue("--color-text").trim() + const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim() + const colorBorder = styles.getPropertyValue("--color-border").trim() + const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})` + const liteSuffix = " (go)" + + const dailyDataRegular = new Map>() + const dailyDataSub = new Map>() + const dailyDataLite = new Map>() + for (const dateKey of dates) { + dailyDataRegular.set(dateKey, new Map()) + dailyDataSub.set(dateKey, new Map()) + dailyDataLite.set(dateKey, new Map()) + } + + data.usage + .filter((row) => (store.key ? row.keyId === store.key : true)) + .forEach((row) => { + const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular + const dayMap = targetMap.get(row.date) + if (!dayMap) return + dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost) + }) + + const filteredModels = store.model === null ? getModels() : [store.model] + + // Create datasets: regular first, then subscription, then lite (with visual distinction via opacity) + const datasets = [ + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataRegular.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: model, + data: dates.map((date) => (dailyDataRegular.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: color, + hoverBackgroundColor: color, + borderWidth: 0, + stack: "usage", + } + }), + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: `${model}${subSuffix}`, + data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: addOpacityToColor(color, 0.5), + hoverBackgroundColor: addOpacityToColor(color, 0.7), + borderWidth: 1, + borderColor: color, + stack: "subscription", + } + }), + ...filteredModels + .filter((model) => dates.some((date) => (dailyDataLite.get(date)?.get(model) || 0) > 0)) + .map((model) => { + const color = getModelColor(model) + return { + label: `${model}${liteSuffix}`, + data: dates.map((date) => (dailyDataLite.get(date)?.get(model) || 0) / 100_000_000), + backgroundColor: addOpacityToColor(color, 0.35), + hoverBackgroundColor: addOpacityToColor(color, 0.55), + borderWidth: 1, + borderColor: addOpacityToColor(color, 0.7), + borderDash: [4, 2], + stack: "lite", + } + }), + ] + + 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: colorTextMuted, + font: { + family: "monospace", + size: 11, + }, + }, + }, + y: { + stacked: true, + beginAtZero: true, + grid: { + color: colorBorderMuted, + }, + ticks: { + color: colorTextMuted, + 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: colorBgElevated, + titleColor: colorText, + bodyColor: colorTextSecondary, + borderColor: colorBorder, + borderWidth: 1, + padding: 12, + displayColors: true, + filter: (item) => (item.parsed.y ?? 0) > 0, + callbacks: { + label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`, + }, + }, + legend: { + display: true, + position: "bottom", + labels: { + color: colorTextSecondary, + 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 label = dataset.label || "" + const isSub = label.endsWith(subSuffix) + const isLite = label.endsWith(liteSuffix) + const model = isSub + ? label.slice(0, -subSuffix.length) + : isLite + ? label.slice(0, -liteSuffix.length) + : label + const baseColor = getModelColor(model) + const originalColor = isSub + ? addOpacityToColor(baseColor, 0.5) + : isLite + ? addOpacityToColor(baseColor, 0.35) + : baseColor + const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15) + 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 label = dataset.label || "" + const isSub = label.endsWith(subSuffix) + const isLite = label.endsWith(liteSuffix) + const model = isSub + ? label.slice(0, -subSuffix.length) + : isLite + ? label.slice(0, -liteSuffix.length) + : label + const baseColor = getModelColor(model) + const color = isSub + ? addOpacityToColor(baseColor, 0.5) + : isLite + ? addOpacityToColor(baseColor, 0.35) + : baseColor + meta.data.forEach((bar: any) => { + bar.options.backgroundColor = color + }) + }) + chart.update("none") + }, + }, + }, + }, + } + }) + + createEffect(async () => { + const data = await getCosts(params.id!, store.year, store.month) + setStore({ data }) + }) + + createEffect(() => { + const config = chartConfig() + if (!config || !canvasRef) return + + if (chartInstance) chartInstance.destroy() + chartInstance = new Chart(canvasRef, config) + + onCleanup(() => chartInstance?.destroy()) + }) + + createEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" }) + + const handleColorSchemeChange = (e: MediaQueryListEvent) => { + setStore({ colorScheme: e.matches ? "dark" : "light" }) + } + + mediaQuery.addEventListener("change", handleColorSchemeChange) + onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange)) + }) + + return ( +
+
+

{i18n.t("workspace.cost.title")}

+

{i18n.t("workspace.cost.subtitle")}

+
+ +
+
+ + {formatMonthYear()} + +
+ setStore({ modelDropdownOpen: open })} + > + <> + + + {(model) => ( + + )} + + + + setStore({ keyDropdownOpen: open })} + > + <> + + + {(key) => ( + + )} + + + +
+ + +

{i18n.t("workspace.cost.empty")}

+ + } + > +
+ +
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/index.tsx b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx new file mode 100644 index 000000000..3a9c8db29 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/index.tsx @@ -0,0 +1,21 @@ +import { Show } from "solid-js" +import { createAsync, useParams } from "@solidjs/router" +import { GraphSection } from "./graph-section" +import { UsageSection } from "./usage-section" +import { querySessionInfo } from "../../common" + +export default function () { + const params = useParams() + const user = createAsync(() => querySessionInfo(params.id!)) + + return ( +
+
+ + + + +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css new file mode 100644 index 000000000..00232de88 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css @@ -0,0 +1,185 @@ +.root { + /* Empty state */ + [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); + + p { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + } + + /* Table container */ + [data-slot="usage-table"] { + overflow-x: auto; + } + + /* Table element */ + [data-slot="usage-table-element"] { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); + + thead { + border-bottom: 1px solid var(--color-border); + } + + th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-weight: normal; + color: var(--color-text-muted); + text-transform: uppercase; + } + + td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-muted); + color: var(--color-text-muted); + font-family: var(--font-mono); + + &[data-slot="usage-date"] { + color: var(--color-text-muted); + } + + &[data-slot="usage-model"] { + font-family: var(--font-sans); + color: var(--color-text-secondary); + max-width: 200px; + word-break: break-word; + } + + &[data-slot="usage-cost"] { + color: var(--color-text-muted); + } + + [data-slot="tokens-with-breakdown"] { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + } + + [data-slot="breakdown-button"] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s ease; + + &:hover { + color: var(--color-text); + } + + svg { + width: 16px; + height: 16px; + } + } + + [data-slot="breakdown-popup"] { + position: absolute; + left: 0; + top: 100%; + margin-top: var(--space-2); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + padding: var(--space-2); + z-index: 10; + min-width: 180px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-size: var(--font-size-xs); + + @media (prefers-color-scheme: dark) { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + } + } + + tbody tr:last-child td { + border-bottom: none; + } + } + + /* Pagination */ + [data-slot="pagination"] { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-4) 0; + border-top: 1px solid var(--color-border-muted); + margin-top: var(--space-2); + + button { + padding: var(--space-2) var(--space-4); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + color: var(--color-text); + font-size: var(--font-size-sm); + 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); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + /* Mobile responsive */ + @media (max-width: 40rem) { + [data-slot="usage-table-element"] { + th, + td { + padding: var(--space-2) var(--space-3); + font-size: var(--font-size-xs); + } + + /* Hide Model column on mobile */ + th:nth-child(2), + td:nth-child(2) { + display: none; + } + } + } + + /* Breakdown popup content */ + [data-slot="breakdown-row"] { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-4); + padding: var(--space-1) 0; + } + + [data-slot="breakdown-label"] { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + } + + [data-slot="breakdown-value"] { + color: var(--color-text); + font-weight: 500; + font-size: var(--font-size-xs); + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx new file mode 100644 index 000000000..2cf8ef850 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx @@ -0,0 +1,217 @@ +import { Billing } from "@opencode-ai/console-core/billing.js" +import { createAsync, query, useParams } from "@solidjs/router" +import { createMemo, For, Show, Switch, Match, createEffect, createSignal } from "solid-js" +import { formatDateUTC, formatDateForTable } from "../../common" +import { withActor } from "~/context/auth.withActor" +import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon" +import styles from "./usage-section.module.css" +import { createStore } from "solid-js/store" +import { useI18n } from "~/context/i18n" + +const PAGE_SIZE = 50 + +async function getUsageInfo(workspaceID: string, page: number) { + "use server" + return withActor(async () => { + return await Billing.usages(page, PAGE_SIZE) + }, workspaceID) +} + +const queryUsageInfo = query(getUsageInfo, "usage.list") + +export function UsageSection() { + const params = useParams() + const i18n = useI18n() + const usage = createAsync(() => queryUsageInfo(params.id!, 0)) + const [store, setStore] = createStore({ page: 0, usage: [] as Awaited> }) + const [openBreakdownId, setOpenBreakdownId] = createSignal(null) + + createEffect(() => { + setStore({ usage: usage() }) + }, [usage]) + + createEffect(() => { + if (!openBreakdownId()) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (!target.closest('[data-slot="tokens-with-breakdown"]')) { + setOpenBreakdownId(null) + } + } + + document.addEventListener("click", handleClickOutside) + return () => document.removeEventListener("click", handleClickOutside) + }) + + const hasResults = createMemo(() => store.usage && store.usage.length > 0) + const canGoPrev = createMemo(() => store.page > 0) + const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE) + + const calculateTotalInputTokens = (u: Awaited>[0]) => { + return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0) + } + + const calculateTotalOutputTokens = (u: Awaited>[0]) => { + return u.outputTokens + (u.reasoningTokens ?? 0) + } + + const goPrev = async () => { + const usage = await getUsageInfo(params.id!, store.page - 1) + setStore({ + page: store.page - 1, + usage, + }) + } + const goNext = async () => { + const usage = await getUsageInfo(params.id!, store.page + 1) + setStore({ + page: store.page + 1, + usage, + }) + } + + return ( +
+
+

{i18n.t("workspace.usage.title")}

+

{i18n.t("workspace.usage.subtitle")}

+
+
+ +

{i18n.t("workspace.usage.empty")}

+
+ } + > + + + + + + + + + + + + + + {(usage, index) => { + const date = createMemo(() => new Date(usage.timeCreated)) + const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage)) + const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage)) + const inputBreakdownId = `input-breakdown-${index()}` + const outputBreakdownId = `output-breakdown-${index()}` + const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId) + const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId) + const isClaude = usage.model.toLowerCase().includes("claude") + return ( + + + + + + + + + ) + }} + + +
{i18n.t("workspace.usage.table.date")}{i18n.t("workspace.usage.table.model")}{i18n.t("workspace.usage.table.input")}{i18n.t("workspace.usage.table.output")}{i18n.t("workspace.usage.table.cost")}{i18n.t("workspace.usage.table.session")}
+ {formatDateForTable(date())} + {usage.model} +
e.stopPropagation()}> + + setOpenBreakdownId(null)}>{totalInputTokens()} + +
e.stopPropagation()}> +
+ {i18n.t("workspace.usage.breakdown.input")} + {usage.inputTokens} +
+
+ {i18n.t("workspace.usage.breakdown.cacheRead")} + {usage.cacheReadTokens ?? 0} +
+ +
+ + {i18n.t("workspace.usage.breakdown.cacheWrite")} + + {usage.cacheWrite5mTokens ?? 0} +
+
+
+
+
+
+
e.stopPropagation()}> + + setOpenBreakdownId(null)}>{totalOutputTokens()} + +
e.stopPropagation()}> +
+ {i18n.t("workspace.usage.breakdown.output")} + {usage.outputTokens} +
+
+ {i18n.t("workspace.usage.breakdown.reasoning")} + {usage.reasoningTokens ?? 0} +
+
+
+
+
+ ${((usage.cost ?? 0) / 100000000).toFixed(4)}}> + + {i18n.t("workspace.usage.subscription", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + + {i18n.t("workspace.usage.lite", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + + {i18n.t("workspace.usage.byok", { + amount: ((usage.cost ?? 0) / 100000000).toFixed(4), + })} + + + {usage.sessionID?.slice(-8) ?? "-"}
+ +
+ + +
+
+ + +
+ ) +} -- cgit v1.2.3