Skip to content

Commit 0e12dd6

Browse files
author
Frank
committed
zen: usage paging
1 parent 2b957b5 commit 0e12dd6

3 files changed

Lines changed: 145 additions & 143 deletions

File tree

Lines changed: 94 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,111 @@
1-
.root {
2-
[data-component="empty-state"] {
3-
padding: var(--space-20) var(--space-6);
4-
text-align: center;
5-
border: 1px dashed var(--color-border);
6-
border-radius: var(--border-radius-sm);
7-
display: flex;
8-
flex-direction: column;
9-
gap: var(--space-2);
10-
11-
p {
12-
line-height: 1.5;
13-
font-size: var(--font-size-sm);
14-
color: var(--color-text-muted);
1+
/* Empty state */
2+
[data-component="empty-state"] {
3+
padding: var(--space-20) var(--space-6);
4+
text-align: center;
5+
border: 1px dashed var(--color-border);
6+
border-radius: var(--border-radius-sm);
7+
8+
p {
9+
font-size: var(--font-size-sm);
10+
color: var(--color-text-muted);
11+
}
12+
}
13+
14+
/* Table container */
15+
[data-slot="usage-table"] {
16+
overflow-x: auto;
17+
}
18+
19+
/* Table element */
20+
[data-slot="usage-table-element"] {
21+
width: 100%;
22+
border-collapse: collapse;
23+
font-size: var(--font-size-sm);
24+
25+
thead {
26+
border-bottom: 1px solid var(--color-border);
27+
}
28+
29+
th {
30+
padding: var(--space-3) var(--space-4);
31+
text-align: left;
32+
font-weight: normal;
33+
color: var(--color-text-muted);
34+
text-transform: uppercase;
35+
}
36+
37+
td {
38+
padding: var(--space-3) var(--space-4);
39+
border-bottom: 1px solid var(--color-border-muted);
40+
color: var(--color-text-muted);
41+
font-family: var(--font-mono);
42+
43+
&[data-slot="usage-date"] {
44+
color: var(--color-text);
45+
}
46+
47+
&[data-slot="usage-model"] {
48+
font-family: var(--font-sans);
49+
color: var(--color-text-secondary);
50+
max-width: 200px;
51+
word-break: break-word;
52+
}
53+
54+
&[data-slot="usage-cost"] {
55+
color: var(--color-text);
56+
font-weight: 500;
1557
}
1658
}
1759

18-
[data-slot="usage-table"] {
19-
overflow-x: auto;
60+
tbody tr:last-child td {
61+
border-bottom: none;
2062
}
63+
}
2164

22-
[data-slot="usage-table-element"] {
23-
width: 100%;
24-
border-collapse: collapse;
65+
/* Pagination */
66+
[data-slot="pagination"] {
67+
display: flex;
68+
justify-content: flex-end;
69+
gap: var(--space-2);
70+
padding: var(--space-4) 0;
71+
border-top: 1px solid var(--color-border-muted);
72+
margin-top: var(--space-2);
73+
74+
button {
75+
padding: var(--space-2) var(--space-4);
76+
background: var(--color-bg-secondary);
77+
border: 1px solid var(--color-border);
78+
border-radius: var(--border-radius-sm);
79+
color: var(--color-text);
2580
font-size: var(--font-size-sm);
81+
cursor: pointer;
82+
transition: all 0.15s ease;
2683

27-
thead {
28-
border-bottom: 1px solid var(--color-border);
84+
&:hover:not(:disabled) {
85+
background: var(--color-bg-tertiary);
86+
border-color: var(--color-border-hover);
2987
}
3088

31-
th {
32-
padding: var(--space-3) var(--space-4);
33-
text-align: left;
34-
font-weight: normal;
35-
color: var(--color-text-muted);
36-
text-transform: uppercase;
89+
&:disabled {
90+
opacity: 0.5;
91+
cursor: not-allowed;
3792
}
93+
}
94+
}
3895

96+
/* Mobile responsive */
97+
@media (max-width: 40rem) {
98+
[data-slot="usage-table-element"] {
99+
th,
39100
td {
40-
padding: var(--space-3) var(--space-4);
41-
border-bottom: 1px solid var(--color-border-muted);
42-
color: var(--color-text-muted);
43-
font-family: var(--font-mono);
44-
45-
&[data-slot="usage-date"] {
46-
color: var(--color-text);
47-
}
48-
49-
&[data-slot="usage-model"] {
50-
font-family: var(--font-sans);
51-
font-weight: 400;
52-
color: var(--color-text-secondary);
53-
max-width: 200px;
54-
word-break: break-word;
55-
}
56-
57-
&[data-slot="usage-cost"] {
58-
color: var(--color-text);
59-
}
60-
}
61-
62-
tbody tr {
63-
&:last-child td {
64-
border-bottom: none;
65-
}
101+
padding: var(--space-2) var(--space-3);
102+
font-size: var(--font-size-xs);
66103
}
67104

68-
@media (max-width: 40rem) {
69-
th,
70-
td {
71-
padding: var(--space-2) var(--space-3);
72-
font-size: var(--font-size-xs);
73-
}
74-
75-
th {
76-
&:nth-child(2) /* Model */ {
77-
display: none;
78-
}
79-
}
80-
81-
td {
82-
&:nth-child(2) /* Model */ {
83-
display: none;
84-
}
85-
}
105+
/* Hide Model column on mobile */
106+
th:nth-child(2),
107+
td:nth-child(2) {
108+
display: none;
86109
}
87110
}
88111
}

packages/console/app/src/routes/workspace/[id]/usage-section.tsx

Lines changed: 48 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,59 @@
11
import { Billing } from "@opencode-ai/console-core/billing.js"
2-
import { query, useParams, createAsync } from "@solidjs/router"
3-
import { createMemo, For, Show } from "solid-js"
2+
import { createAsync, query, useParams } from "@solidjs/router"
3+
import { createMemo, For, Show, createEffect } from "solid-js"
44
import { formatDateUTC, formatDateForTable } from "../common"
55
import { withActor } from "~/context/auth.withActor"
6-
import styles from "./usage-section.module.css"
6+
import "./usage-section.module.css"
7+
import { createStore } from "solid-js/store"
78

8-
const getUsageInfo = query(async (workspaceID: string) => {
9+
const PAGE_SIZE = 50
10+
11+
async function getUsageInfo(workspaceID: string, page: number) {
912
"use server"
1013
return withActor(async () => {
11-
return await Billing.usages()
14+
return await Billing.usages(page, PAGE_SIZE)
1215
}, workspaceID)
13-
}, "usage.list")
16+
}
17+
18+
const queryUsageInfo = query(getUsageInfo, "usage.list")
1419

1520
export function UsageSection() {
1621
const params = useParams()
17-
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
18-
const usage = createAsync(() => getUsageInfo(params.id!))
22+
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
23+
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
1924

20-
// DUMMY DATA FOR TESTING
21-
// const usage = () => [
22-
// {
23-
// timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
24-
// model: "claude-3-5-sonnet-20241022",
25-
// inputTokens: 1247,
26-
// outputTokens: 423,
27-
// cost: 125400000, // $1.254
28-
// },
29-
// {
30-
// timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
31-
// model: "claude-3-haiku-20240307",
32-
// inputTokens: 892,
33-
// outputTokens: 156,
34-
// cost: 23500000, // $0.235
35-
// },
36-
// {
37-
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
38-
// model: "claude-3-5-sonnet-20241022",
39-
// inputTokens: 2134,
40-
// outputTokens: 687,
41-
// cost: 234700000, // $2.347
42-
// },
43-
// {
44-
// timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
45-
// model: "gpt-4o-mini",
46-
// inputTokens: 567,
47-
// outputTokens: 234,
48-
// cost: 8900000, // $0.089
49-
// },
50-
// {
51-
// timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
52-
// model: "claude-3-opus-20240229",
53-
// inputTokens: 1893,
54-
// outputTokens: 945,
55-
// cost: 445600000, // $4.456
56-
// },
57-
// {
58-
// timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
59-
// model: "gpt-4o",
60-
// inputTokens: 1456,
61-
// outputTokens: 532,
62-
// cost: 156800000, // $1.568
63-
// },
64-
// {
65-
// timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
66-
// model: "claude-3-haiku-20240307",
67-
// inputTokens: 634,
68-
// outputTokens: 89,
69-
// cost: 12300000, // $0.123
70-
// },
71-
// {
72-
// timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
73-
// model: "claude-3-5-sonnet-20241022",
74-
// inputTokens: 3245,
75-
// outputTokens: 1123,
76-
// cost: 387200000, // $3.872
77-
// },
78-
// ]
25+
createEffect(() => {
26+
setStore({ usage: usage() })
27+
}, [usage])
28+
29+
const hasResults = createMemo(() => store.usage.length > 0)
30+
const canGoPrev = createMemo(() => store.page > 0)
31+
const canGoNext = createMemo(() => store.usage.length === PAGE_SIZE)
32+
33+
const goPrev = async () => {
34+
const usage = await getUsageInfo(params.id!, store.page - 1)
35+
setStore({
36+
page: store.page - 1,
37+
usage,
38+
})
39+
}
40+
const goNext = async () => {
41+
const usage = await getUsageInfo(params.id!, store.page + 1)
42+
setStore({
43+
page: store.page + 1,
44+
usage,
45+
})
46+
}
7947

8048
return (
81-
<section class={styles.root}>
49+
<section>
8250
<div data-slot="section-title">
8351
<h2>Usage History</h2>
8452
<p>Recent API usage and costs.</p>
8553
</div>
8654
<div data-slot="usage-table">
8755
<Show
88-
when={usage() && usage()!.length > 0}
56+
when={hasResults()}
8957
fallback={
9058
<div data-component="empty-state">
9159
<p>Make your first API call to get started.</p>
@@ -103,7 +71,7 @@ export function UsageSection() {
10371
</tr>
10472
</thead>
10573
<tbody>
106-
<For each={usage()!}>
74+
<For each={store.usage}>
10775
{(usage) => {
10876
const date = createMemo(() => new Date(usage.timeCreated))
10977
return (
@@ -121,6 +89,16 @@ export function UsageSection() {
12189
</For>
12290
</tbody>
12391
</table>
92+
<Show when={canGoPrev() || canGoNext()}>
93+
<div data-slot="pagination">
94+
<button disabled={!canGoPrev()} onClick={goPrev}>
95+
96+
</button>
97+
<button disabled={!canGoNext()} onClick={goNext}>
98+
99+
</button>
100+
</div>
101+
</Show>
124102
</Show>
125103
</div>
126104
</section>

packages/console/core/src/billing.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ export namespace Billing {
5757
)
5858
}
5959

60-
export const usages = async () => {
60+
export const usages = async (page = 0, pageSize = 50) => {
6161
return await Database.use((tx) =>
6262
tx
6363
.select()
6464
.from(UsageTable)
6565
.where(eq(UsageTable.workspaceID, Actor.workspace()))
6666
.orderBy(sql`${UsageTable.timeCreated} DESC`)
67-
.limit(100),
67+
.limit(pageSize)
68+
.offset(page * pageSize),
6869
)
6970
}
7071

0 commit comments

Comments
 (0)