From 0e12dd62a3a4af8d00c0d1d5a445f0071bef47ab Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 16 Nov 2025 03:29:49 -0500 Subject: [PATCH] zen: usage paging --- .../workspace/[id]/usage-section.module.css | 169 ++++++++++-------- .../routes/workspace/[id]/usage-section.tsx | 118 +++++------- packages/console/core/src/billing.ts | 5 +- 3 files changed, 147 insertions(+), 145 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 1a772ba8..2bd331bd 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); +/* 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 { - line-height: 1.5; - font-size: var(--font-size-sm); - color: var(--color-text-muted); - } - } - - [data-slot="usage-table"] { - overflow-x: auto; - } - - [data-slot="usage-table-element"] { - width: 100%; - border-collapse: collapse; + p { font-size: var(--font-size-sm); + color: var(--color-text-muted); + } +} - thead { - border-bottom: 1px solid var(--color-border); +/* 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); } - th { - padding: var(--space-3) var(--space-4); - text-align: left; - font-weight: normal; - color: var(--color-text-muted); - text-transform: uppercase; + &[data-slot="usage-model"] { + font-family: var(--font-sans); + color: var(--color-text-secondary); + max-width: 200px; + word-break: break-word; } - 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-cost"] { + color: var(--color-text); + font-weight: 500; + } + } - &[data-slot="usage-date"] { - color: var(--color-text); - } + tbody tr:last-child td { + border-bottom: none; + } +} - &[data-slot="usage-model"] { - font-family: var(--font-sans); - font-weight: 400; - color: var(--color-text-secondary); - max-width: 200px; - word-break: break-word; - } +/* 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); - &[data-slot="usage-cost"] { - color: var(--color-text); - } + 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; + + &:hover:not(:disabled) { + background: var(--color-bg-tertiary); + border-color: var(--color-border-hover); } - tbody tr { - &:last-child td { - border-bottom: none; - } - } - - @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; - } - } + &: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; } } } 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 3618bb7e..df8c8413 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> }) - // 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 ( -
+

Usage History

Recent API usage and costs.

0} + when={hasResults()} fallback={

Make your first API call to get started.

@@ -103,7 +71,7 @@ export function UsageSection() { - + {(usage) => { const date = createMemo(() => new Date(usage.timeCreated)) return ( @@ -121,6 +89,16 @@ export function UsageSection() { + +
+ + +
+
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 34871814..049ee29b 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), ) }