mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-30 01:36:47 +00:00
209 lines
9.2 KiB
TypeScript
209 lines
9.2 KiB
TypeScript
import { Billing } from "@opencode-ai/console-core/billing.js"
|
|
import { createAsync, query, useParams } from "@solidjs/router"
|
|
import { createMemo, For, Show, 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<ReturnType<typeof getUsageInfo>> })
|
|
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(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<ReturnType<typeof getUsageInfo>>[0]) => {
|
|
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
|
|
}
|
|
|
|
const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[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 (
|
|
<section class={styles.root}>
|
|
<div data-slot="section-title">
|
|
<h2>{i18n.t("workspace.usage.title")}</h2>
|
|
<p>{i18n.t("workspace.usage.subtitle")}</p>
|
|
</div>
|
|
<div data-slot="usage-table">
|
|
<Show
|
|
when={hasResults()}
|
|
fallback={
|
|
<div data-component="empty-state">
|
|
<p>{i18n.t("workspace.usage.empty")}</p>
|
|
</div>
|
|
}
|
|
>
|
|
<table data-slot="usage-table-element">
|
|
<thead>
|
|
<tr>
|
|
<th>{i18n.t("workspace.usage.table.date")}</th>
|
|
<th>{i18n.t("workspace.usage.table.model")}</th>
|
|
<th>{i18n.t("workspace.usage.table.input")}</th>
|
|
<th>{i18n.t("workspace.usage.table.output")}</th>
|
|
<th>{i18n.t("workspace.usage.table.cost")}</th>
|
|
<th>{i18n.t("workspace.usage.table.session")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<For each={store.usage}>
|
|
{(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 (
|
|
<tr>
|
|
<td data-slot="usage-date" title={formatDateUTC(date())}>
|
|
{formatDateForTable(date())}
|
|
</td>
|
|
<td data-slot="usage-model">{usage.model}</td>
|
|
<td data-slot="usage-tokens">
|
|
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
data-slot="breakdown-button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
|
|
}}
|
|
>
|
|
<IconBreakdown />
|
|
</button>
|
|
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
|
|
<Show when={isInputOpen()}>
|
|
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
|
<div data-slot="breakdown-row">
|
|
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.input")}</span>
|
|
<span data-slot="breakdown-value">{usage.inputTokens}</span>
|
|
</div>
|
|
<div data-slot="breakdown-row">
|
|
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.cacheRead")}</span>
|
|
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
|
|
</div>
|
|
<Show when={isClaude}>
|
|
<div data-slot="breakdown-row">
|
|
<span data-slot="breakdown-label">
|
|
{i18n.t("workspace.usage.breakdown.cacheWrite")}
|
|
</span>
|
|
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
<td data-slot="usage-tokens">
|
|
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
data-slot="breakdown-button"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
|
|
}}
|
|
>
|
|
<IconBreakdown />
|
|
</button>
|
|
<span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
|
|
<Show when={isOutputOpen()}>
|
|
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
|
|
<div data-slot="breakdown-row">
|
|
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.output")}</span>
|
|
<span data-slot="breakdown-value">{usage.outputTokens}</span>
|
|
</div>
|
|
<div data-slot="breakdown-row">
|
|
<span data-slot="breakdown-label">{i18n.t("workspace.usage.breakdown.reasoning")}</span>
|
|
<span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
</td>
|
|
<td data-slot="usage-cost">
|
|
<Show
|
|
when={usage.enrichment?.plan === "sub"}
|
|
fallback={<>${((usage.cost ?? 0) / 100000000).toFixed(4)}</>}
|
|
>
|
|
{i18n.t("workspace.usage.subscription", {
|
|
amount: ((usage.cost ?? 0) / 100000000).toFixed(4),
|
|
})}
|
|
</Show>
|
|
</td>
|
|
<td data-slot="usage-session">{usage.sessionID?.slice(-8) ?? "-"}</td>
|
|
</tr>
|
|
)
|
|
}}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
<Show when={canGoPrev() || canGoNext()}>
|
|
<div data-slot="pagination">
|
|
<button disabled={!canGoPrev()} onClick={goPrev}>
|
|
<IconChevronLeft />
|
|
</button>
|
|
<button disabled={!canGoNext()} onClick={goNext}>
|
|
<IconChevronRight />
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|