From 16cb77c094c6ac83a6b1fa0d03a5a6b2ac5d8648 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 18 Nov 2025 00:45:14 -0500 Subject: [PATCH] zen: add usage graph --- bun.lock | 6 +- packages/console/app/package.json | 7 +- packages/console/app/src/component/icon.tsx | 16 + .../workspace/[id]/graph-section.module.css | 141 ++++++ .../routes/workspace/[id]/graph-section.tsx | 419 ++++++++++++++++++ .../app/src/routes/workspace/[id]/index.tsx | 4 + .../workspace/[id]/usage-section.module.css | 6 + .../routes/workspace/[id]/usage-section.tsx | 5 +- 8 files changed, 598 insertions(+), 6 deletions(-) create mode 100644 packages/console/app/src/routes/workspace/[id]/graph-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/graph-section.tsx 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/utils@0.9.1", "", { "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/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.0", "", { "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/mdx@3.1.1", "", { "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": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + "cheerio": ["cheerio@1.0.0-rc.12", "", { "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": ["cheerio-select@2.1.0", "", { "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) { ) } + +export function IconChevronLeft(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + +export function IconChevronRight(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} 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`DATE(${UsageTable.timeCreated})`, + model: UsageTable.model, + totalCost: sql`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 = { + "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> | 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>() + 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 ( +
+
+

Cost

+

Usage costs broken down by model.

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

No usage data available for the selected period.

+ + } + > +
+ +
+
+
+ ) +} 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 () {
+ + + 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() {