summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-11-16 03:29:49 -0500
committerFrank <[email protected]>2025-11-16 03:29:52 -0500
commit0e12dd62a3a4af8d00c0d1d5a445f0071bef47ab (patch)
tree0a7aeaa74f8d01dca94450b96bba93fecff22a7b
parent2b957b5d1ce45c2413e39869a0ce9f3c81ea80fe (diff)
downloadopencode-0e12dd62a3a4af8d00c0d1d5a445f0071bef47ab.tar.gz
opencode-0e12dd62a3a4af8d00c0d1d5a445f0071bef47ab.zip
zen: usage paging
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.module.css165
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx118
-rw-r--r--packages/console/core/src/billing.ts5
3 files changed, 145 insertions, 143 deletions
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 1a772ba87..2bd331bd9 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
@@ -1,88 +1,111 @@
-.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);
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
-
- p {
- line-height: 1.5;
- font-size: var(--font-size-sm);
- color: var(--color-text-muted);
+/* 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);
+ }
+
+ &[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);
+ font-weight: 500;
}
}
- [data-slot="usage-table"] {
- overflow-x: auto;
+ tbody tr:last-child td {
+ border-bottom: none;
}
+}
- [data-slot="usage-table-element"] {
- width: 100%;
- border-collapse: collapse;
+/* 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;
- thead {
- border-bottom: 1px solid var(--color-border);
+ &:hover:not(:disabled) {
+ background: var(--color-bg-tertiary);
+ border-color: var(--color-border-hover);
}
- th {
- padding: var(--space-3) var(--space-4);
- text-align: left;
- font-weight: normal;
- color: var(--color-text-muted);
- text-transform: uppercase;
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
}
+ }
+}
+/* Mobile responsive */
+@media (max-width: 40rem) {
+ [data-slot="usage-table-element"] {
+ th,
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);
- }
-
- &[data-slot="usage-model"] {
- font-family: var(--font-sans);
- font-weight: 400;
- color: var(--color-text-secondary);
- max-width: 200px;
- word-break: break-word;
- }
-
- &[data-slot="usage-cost"] {
- color: var(--color-text);
- }
- }
-
- tbody tr {
- &:last-child td {
- border-bottom: none;
- }
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
}
- @media (max-width: 40rem) {
- th,
- td {
- padding: var(--space-2) var(--space-3);
- font-size: var(--font-size-xs);
- }
-
- th {
- &:nth-child(2) /* Model */ {
- display: none;
- }
- }
-
- td {
- &:nth-child(2) /* Model */ {
- display: none;
- }
- }
+ /* Hide Model column on mobile */
+ th:nth-child(2),
+ td:nth-child(2) {
+ display: none;
}
}
}
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 3618bb7e2..df8c84133 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -1,91 +1,59 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
-import { query, useParams, createAsync } from "@solidjs/router"
-import { createMemo, For, Show } from "solid-js"
+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 styles from "./usage-section.module.css"
+import "./usage-section.module.css"
+import { createStore } from "solid-js/store"
-const getUsageInfo = query(async (workspaceID: string) => {
+const PAGE_SIZE = 50
+
+async function getUsageInfo(workspaceID: string, page: number) {
"use server"
return withActor(async () => {
- return await Billing.usages()
+ return await Billing.usages(page, PAGE_SIZE)
}, workspaceID)
-}, "usage.list")
+}
+
+const queryUsageInfo = query(getUsageInfo, "usage.list")
export function UsageSection() {
const params = useParams()
- // ORIGINAL CODE - COMMENTED OUT FOR TESTING
- const usage = createAsync(() => getUsageInfo(params.id!))
+ const usage = createAsync(() => queryUsageInfo(params.id!, 0))
+ const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
- // DUMMY DATA FOR TESTING
- // const usage = () => [
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
- // model: "claude-3-5-sonnet-20241022",
- // inputTokens: 1247,
- // outputTokens: 423,
- // cost: 125400000, // $1.254
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
- // model: "claude-3-haiku-20240307",
- // inputTokens: 892,
- // outputTokens: 156,
- // cost: 23500000, // $0.235
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
- // model: "claude-3-5-sonnet-20241022",
- // inputTokens: 2134,
- // outputTokens: 687,
- // cost: 234700000, // $2.347
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
- // model: "gpt-4o-mini",
- // inputTokens: 567,
- // outputTokens: 234,
- // cost: 8900000, // $0.089
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
- // model: "claude-3-opus-20240229",
- // inputTokens: 1893,
- // outputTokens: 945,
- // cost: 445600000, // $4.456
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
- // model: "gpt-4o",
- // inputTokens: 1456,
- // outputTokens: 532,
- // cost: 156800000, // $1.568
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
- // model: "claude-3-haiku-20240307",
- // inputTokens: 634,
- // outputTokens: 89,
- // cost: 12300000, // $0.123
- // },
- // {
- // timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
- // model: "claude-3-5-sonnet-20241022",
- // inputTokens: 3245,
- // outputTokens: 1123,
- // cost: 387200000, // $3.872
- // },
- // ]
+ createEffect(() => {
+ setStore({ usage: usage() })
+ }, [usage])
+
+ const hasResults = createMemo(() => store.usage.length > 0)
+ const canGoPrev = createMemo(() => store.page > 0)
+ const canGoNext = createMemo(() => store.usage.length === PAGE_SIZE)
+
+ 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}>
+ <section>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
- when={usage() && usage()!.length > 0}
+ when={hasResults()}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
@@ -103,7 +71,7 @@ export function UsageSection() {
</tr>
</thead>
<tbody>
- <For each={usage()!}>
+ <For each={store.usage}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
@@ -121,6 +89,16 @@ export function UsageSection() {
</For>
</tbody>
</table>
+ <Show when={canGoPrev() || canGoNext()}>
+ <div data-slot="pagination">
+ <button disabled={!canGoPrev()} onClick={goPrev}>
+ ←
+ </button>
+ <button disabled={!canGoNext()} onClick={goNext}>
+ →
+ </button>
+ </div>
+ </Show>
</Show>
</div>
</section>
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
index 348718146..049ee29bb 100644
--- a/packages/console/core/src/billing.ts
+++ b/packages/console/core/src/billing.ts
@@ -57,14 +57,15 @@ export namespace Billing {
)
}
- export const usages = async () => {
+ export const usages = async (page = 0, pageSize = 50) => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
- .limit(100),
+ .limit(pageSize)
+ .offset(page * pageSize),
)
}