|
|
@@ -3,7 +3,7 @@ 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 { 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"
|
|
|
@@ -94,8 +94,6 @@ async function getCosts(workspaceID: string, year: number, month: number) {
|
|
|
}, workspaceID)
|
|
|
}
|
|
|
|
|
|
-const queryCosts = query(getCosts, "costs.get")
|
|
|
-
|
|
|
const MODEL_COLORS: Record<string, string> = {
|
|
|
"claude-sonnet-4-5": "#D4745C",
|
|
|
"claude-sonnet-4": "#E8B4A4",
|
|
|
@@ -160,45 +158,25 @@ export function GraphSection() {
|
|
|
keyDropdownOpen: false,
|
|
|
colorScheme: "light" as "light" | "dark",
|
|
|
})
|
|
|
- const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- if (typeof window === "undefined") return
|
|
|
-
|
|
|
- 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))
|
|
|
- })
|
|
|
-
|
|
|
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 })
|
|
|
+ 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, data: await getCosts(params.id!, year, month) })
|
|
|
+ setStore({ month, year })
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
+ if (!store.data?.usage) return []
|
|
|
+ return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
|
|
|
})
|
|
|
|
|
|
const getDates = createMemo(() => {
|
|
|
@@ -221,9 +199,7 @@ export function GraphSection() {
|
|
|
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
|
|
|
|
|
|
const chartConfig = createMemo((): ChartConfiguration | null => {
|
|
|
- if (typeof window === "undefined") return null
|
|
|
-
|
|
|
- const data = getData()
|
|
|
+ const data = store.data
|
|
|
const dates = getDates()
|
|
|
if (!data?.usage?.length) return null
|
|
|
|
|
|
@@ -365,15 +341,32 @@ export function GraphSection() {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ 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())
|
|
|
})
|
|
|
|
|
|
- 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 (
|
|
|
<section class={styles.root}>
|
|
|
@@ -382,55 +375,53 @@ export function GraphSection() {
|
|
|
<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 />
|
|
|
+ <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>
|
|
|
- <span data-slot="month-label">{formatMonthYear()}</span>
|
|
|
- <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
|
|
|
- <IconChevronRight />
|
|
|
+ <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>
|
|
|
- </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>
|
|
|
+ <For each={store.data?.keys || []}>
|
|
|
+ {(key) => (
|
|
|
+ <button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
|
|
|
+ <span>{key.displayName}</span>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </>
|
|
|
+ </Dropdown>
|
|
|
+ </div>
|
|
|
|
|
|
<Show
|
|
|
when={chartConfig()}
|