@jonsoc/console-app 1.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.opencode/agent/css.md +149 -0
  2. package/README.md +32 -0
  3. package/package.json +49 -0
  4. package/public/apple-touch-icon-v3.png +1 -0
  5. package/public/apple-touch-icon.png +1 -0
  6. package/public/email +1 -0
  7. package/public/favicon-96x96-v3.png +1 -0
  8. package/public/favicon-96x96.png +1 -0
  9. package/public/favicon-v3.ico +1 -0
  10. package/public/favicon-v3.svg +1 -0
  11. package/public/favicon.ico +1 -0
  12. package/public/favicon.svg +1 -0
  13. package/public/opencode-brand-assets.zip +0 -0
  14. package/public/robots.txt +6 -0
  15. package/public/site.webmanifest +1 -0
  16. package/public/social-share-black.png +1 -0
  17. package/public/social-share-zen.png +1 -0
  18. package/public/social-share.png +1 -0
  19. package/public/theme.json +182 -0
  20. package/public/web-app-manifest-192x192.png +1 -0
  21. package/public/web-app-manifest-512x512.png +1 -0
  22. package/script/generate-sitemap.ts +103 -0
  23. package/src/app.css +1 -0
  24. package/src/app.tsx +27 -0
  25. package/src/asset/black/hero.png +0 -0
  26. package/src/asset/brand/opencode-brand-assets.zip +0 -0
  27. package/src/asset/brand/opencode-logo-dark.png +0 -0
  28. package/src/asset/brand/opencode-logo-dark.svg +16 -0
  29. package/src/asset/brand/opencode-logo-light.png +0 -0
  30. package/src/asset/brand/opencode-logo-light.svg +16 -0
  31. package/src/asset/brand/opencode-wordmark-dark.png +0 -0
  32. package/src/asset/brand/opencode-wordmark-dark.svg +30 -0
  33. package/src/asset/brand/opencode-wordmark-light.png +0 -0
  34. package/src/asset/brand/opencode-wordmark-light.svg +30 -0
  35. package/src/asset/brand/opencode-wordmark-simple-dark.png +0 -0
  36. package/src/asset/brand/opencode-wordmark-simple-dark.svg +22 -0
  37. package/src/asset/brand/opencode-wordmark-simple-light.png +0 -0
  38. package/src/asset/brand/opencode-wordmark-simple-light.svg +22 -0
  39. package/src/asset/brand/preview-opencode-dark.png +0 -0
  40. package/src/asset/brand/preview-opencode-logo-dark.png +0 -0
  41. package/src/asset/brand/preview-opencode-logo-light.png +0 -0
  42. package/src/asset/brand/preview-opencode-wordmark-dark.png +0 -0
  43. package/src/asset/brand/preview-opencode-wordmark-light.png +0 -0
  44. package/src/asset/brand/preview-opencode-wordmark-simple-dark.png +0 -0
  45. package/src/asset/brand/preview-opencode-wordmark-simple-light.png +0 -0
  46. package/src/asset/lander/avatar-adam.png +0 -0
  47. package/src/asset/lander/avatar-david.png +0 -0
  48. package/src/asset/lander/avatar-dax.png +0 -0
  49. package/src/asset/lander/avatar-frank.png +0 -0
  50. package/src/asset/lander/avatar-jay.png +0 -0
  51. package/src/asset/lander/brand-assets-dark.svg +10 -0
  52. package/src/asset/lander/brand-assets-light.svg +10 -0
  53. package/src/asset/lander/brand.png +0 -0
  54. package/src/asset/lander/check.svg +3 -0
  55. package/src/asset/lander/copy.svg +3 -0
  56. package/src/asset/lander/desktop-app-icon.png +0 -0
  57. package/src/asset/lander/dock.png +0 -0
  58. package/src/asset/lander/logo-dark.svg +11 -0
  59. package/src/asset/lander/logo-light.svg +11 -0
  60. package/src/asset/lander/opencode-comparison-min.mp4 +0 -0
  61. package/src/asset/lander/opencode-comparison-poster.png +0 -0
  62. package/src/asset/lander/opencode-desktop-icon.png +0 -0
  63. package/src/asset/lander/opencode-logo-dark.svg +11 -0
  64. package/src/asset/lander/opencode-logo-light.svg +11 -0
  65. package/src/asset/lander/opencode-min.mp4 +0 -0
  66. package/src/asset/lander/opencode-poster.png +0 -0
  67. package/src/asset/lander/opencode-wordmark-dark.svg +25 -0
  68. package/src/asset/lander/opencode-wordmark-light.svg +25 -0
  69. package/src/asset/lander/screenshot-github.png +0 -0
  70. package/src/asset/lander/screenshot-splash.png +0 -0
  71. package/src/asset/lander/screenshot-vscode.png +0 -0
  72. package/src/asset/lander/screenshot.png +0 -0
  73. package/src/asset/lander/wordmark-dark.svg +3 -0
  74. package/src/asset/lander/wordmark-light.svg +3 -0
  75. package/src/asset/logo-ornate-dark.svg +18 -0
  76. package/src/asset/logo-ornate-light.svg +18 -0
  77. package/src/asset/logo.svg +18 -0
  78. package/src/asset/zen-ornate-dark.svg +8 -0
  79. package/src/asset/zen-ornate-light.svg +8 -0
  80. package/src/component/dropdown.css +80 -0
  81. package/src/component/dropdown.tsx +79 -0
  82. package/src/component/email-signup.tsx +48 -0
  83. package/src/component/faq.tsx +33 -0
  84. package/src/component/footer.tsx +38 -0
  85. package/src/component/header-context-menu.css +63 -0
  86. package/src/component/header.tsx +279 -0
  87. package/src/component/icon.tsx +257 -0
  88. package/src/component/legal.tsx +20 -0
  89. package/src/component/modal.css +66 -0
  90. package/src/component/modal.tsx +24 -0
  91. package/src/component/spotlight.css +15 -0
  92. package/src/component/spotlight.tsx +820 -0
  93. package/src/config.ts +29 -0
  94. package/src/context/auth.session.ts +0 -0
  95. package/src/context/auth.ts +116 -0
  96. package/src/context/auth.withActor.ts +7 -0
  97. package/src/entry-client.tsx +4 -0
  98. package/src/entry-server.tsx +30 -0
  99. package/src/global.d.ts +5 -0
  100. package/src/lib/github.ts +38 -0
  101. package/src/middleware.ts +5 -0
  102. package/src/routes/[...404].css +130 -0
  103. package/src/routes/[...404].tsx +38 -0
  104. package/src/routes/api/enterprise.ts +47 -0
  105. package/src/routes/auth/[...callback].ts +41 -0
  106. package/src/routes/auth/authorize.ts +10 -0
  107. package/src/routes/auth/index.ts +12 -0
  108. package/src/routes/auth/logout.ts +17 -0
  109. package/src/routes/auth/status.ts +7 -0
  110. package/src/routes/bench/[id].tsx +365 -0
  111. package/src/routes/bench/index.tsx +86 -0
  112. package/src/routes/bench/submission.ts +29 -0
  113. package/src/routes/black/common.tsx +62 -0
  114. package/src/routes/black/index.tsx +108 -0
  115. package/src/routes/black/subscribe/[plan].tsx +449 -0
  116. package/src/routes/black/workspace.css +214 -0
  117. package/src/routes/black/workspace.tsx +229 -0
  118. package/src/routes/black.css +828 -0
  119. package/src/routes/black.tsx +285 -0
  120. package/src/routes/brand/index.css +555 -0
  121. package/src/routes/brand/index.tsx +252 -0
  122. package/src/routes/changelog/index.css +477 -0
  123. package/src/routes/changelog/index.tsx +147 -0
  124. package/src/routes/debug/index.ts +13 -0
  125. package/src/routes/desktop-feedback.ts +5 -0
  126. package/src/routes/discord.ts +5 -0
  127. package/src/routes/docs/[...path].ts +20 -0
  128. package/src/routes/docs/index.ts +20 -0
  129. package/src/routes/download/[platform].ts +38 -0
  130. package/src/routes/download/index.css +750 -0
  131. package/src/routes/download/index.tsx +482 -0
  132. package/src/routes/download/types.ts +4 -0
  133. package/src/routes/enterprise/index.css +578 -0
  134. package/src/routes/enterprise/index.tsx +251 -0
  135. package/src/routes/index.css +1251 -0
  136. package/src/routes/index.tsx +840 -0
  137. package/src/routes/legal/privacy-policy/index.css +343 -0
  138. package/src/routes/legal/privacy-policy/index.tsx +1512 -0
  139. package/src/routes/legal/terms-of-service/index.css +254 -0
  140. package/src/routes/legal/terms-of-service/index.tsx +512 -0
  141. package/src/routes/openapi.json.ts +7 -0
  142. package/src/routes/s/[id].ts +20 -0
  143. package/src/routes/stripe/webhook.ts +532 -0
  144. package/src/routes/t/[...path].tsx +20 -0
  145. package/src/routes/temp.tsx +172 -0
  146. package/src/routes/user-menu.css +18 -0
  147. package/src/routes/user-menu.tsx +32 -0
  148. package/src/routes/workspace/[id]/billing/billing-section.module.css +185 -0
  149. package/src/routes/workspace/[id]/billing/billing-section.tsx +240 -0
  150. package/src/routes/workspace/[id]/billing/black-section.module.css +142 -0
  151. package/src/routes/workspace/[id]/billing/black-section.tsx +269 -0
  152. package/src/routes/workspace/[id]/billing/black-waitlist-section.module.css +23 -0
  153. package/src/routes/workspace/[id]/billing/index.tsx +32 -0
  154. package/src/routes/workspace/[id]/billing/monthly-limit-section.module.css +96 -0
  155. package/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +133 -0
  156. package/src/routes/workspace/[id]/billing/payment-section.module.css +93 -0
  157. package/src/routes/workspace/[id]/billing/payment-section.tsx +122 -0
  158. package/src/routes/workspace/[id]/billing/reload-section.module.css +261 -0
  159. package/src/routes/workspace/[id]/billing/reload-section.tsx +213 -0
  160. package/src/routes/workspace/[id]/graph-section.module.css +145 -0
  161. package/src/routes/workspace/[id]/graph-section.tsx +475 -0
  162. package/src/routes/workspace/[id]/index.tsx +81 -0
  163. package/src/routes/workspace/[id]/keys/index.tsx +11 -0
  164. package/src/routes/workspace/[id]/keys/key-section.module.css +197 -0
  165. package/src/routes/workspace/[id]/keys/key-section.tsx +176 -0
  166. package/src/routes/workspace/[id]/members/index.tsx +11 -0
  167. package/src/routes/workspace/[id]/members/member-section.module.css +249 -0
  168. package/src/routes/workspace/[id]/members/member-section.tsx +343 -0
  169. package/src/routes/workspace/[id]/members/role-dropdown.css +72 -0
  170. package/src/routes/workspace/[id]/members/role-dropdown.tsx +43 -0
  171. package/src/routes/workspace/[id]/model-section.module.css +173 -0
  172. package/src/routes/workspace/[id]/model-section.tsx +174 -0
  173. package/src/routes/workspace/[id]/new-user-section.module.css +143 -0
  174. package/src/routes/workspace/[id]/new-user-section.tsx +104 -0
  175. package/src/routes/workspace/[id]/provider-section.module.css +138 -0
  176. package/src/routes/workspace/[id]/provider-section.tsx +188 -0
  177. package/src/routes/workspace/[id]/settings/index.tsx +11 -0
  178. package/src/routes/workspace/[id]/settings/settings-section.module.css +94 -0
  179. package/src/routes/workspace/[id]/settings/settings-section.tsx +122 -0
  180. package/src/routes/workspace/[id]/usage-section.module.css +185 -0
  181. package/src/routes/workspace/[id]/usage-section.tsx +200 -0
  182. package/src/routes/workspace/[id].css +308 -0
  183. package/src/routes/workspace/[id].tsx +62 -0
  184. package/src/routes/workspace/common.tsx +120 -0
  185. package/src/routes/workspace-picker.css +74 -0
  186. package/src/routes/workspace-picker.tsx +122 -0
  187. package/src/routes/workspace.css +107 -0
  188. package/src/routes/workspace.tsx +38 -0
  189. package/src/routes/zen/index.css +866 -0
  190. package/src/routes/zen/index.tsx +343 -0
  191. package/src/routes/zen/util/dataDumper.ts +44 -0
  192. package/src/routes/zen/util/error.ts +13 -0
  193. package/src/routes/zen/util/handler.ts +784 -0
  194. package/src/routes/zen/util/logger.ts +12 -0
  195. package/src/routes/zen/util/provider/anthropic.ts +752 -0
  196. package/src/routes/zen/util/provider/google.ts +75 -0
  197. package/src/routes/zen/util/provider/openai-compatible.ts +546 -0
  198. package/src/routes/zen/util/provider/openai.ts +630 -0
  199. package/src/routes/zen/util/provider/provider.ts +210 -0
  200. package/src/routes/zen/util/rateLimiter.ts +41 -0
  201. package/src/routes/zen/util/stickyProviderTracker.ts +16 -0
  202. package/src/routes/zen/util/trialLimiter.ts +49 -0
  203. package/src/routes/zen/v1/chat/completions.ts +11 -0
  204. package/src/routes/zen/v1/messages.ts +11 -0
  205. package/src/routes/zen/v1/models/[model].ts +13 -0
  206. package/src/routes/zen/v1/models.ts +60 -0
  207. package/src/routes/zen/v1/responses.ts +11 -0
  208. package/src/style/base.css +21 -0
  209. package/src/style/component/button.css +102 -0
  210. package/src/style/index.css +8 -0
  211. package/src/style/reset.css +76 -0
  212. package/src/style/token/color.css +91 -0
  213. package/src/style/token/font.css +21 -0
  214. package/src/style/token/space.css +46 -0
  215. package/sst-env.d.ts +9 -0
  216. package/tsconfig.json +21 -0
  217. package/vite.config.ts +25 -0
@@ -0,0 +1,145 @@
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
+ height: 400px;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+
12
+ p {
13
+ font-size: var(--font-size-sm);
14
+ color: var(--color-text-muted);
15
+ }
16
+ }
17
+
18
+ [data-slot="filter-container"] {
19
+ margin-bottom: 0;
20
+ display: flex;
21
+ align-items: center;
22
+ gap: var(--space-3);
23
+
24
+ [data-component="dropdown"] {
25
+ [data-slot="trigger"] {
26
+ border: 1px solid var(--color-border);
27
+ background-color: var(--color-bg);
28
+ padding: var(--space-2) var(--space-3);
29
+ border-radius: var(--border-radius-sm);
30
+ color: var(--color-text);
31
+ font-size: var(--font-size-sm);
32
+ line-height: 1.5;
33
+
34
+ &:hover {
35
+ border-color: var(--color-accent);
36
+ }
37
+
38
+ &:focus {
39
+ outline: none;
40
+ border-color: var(--color-accent);
41
+ box-shadow: 0 0 0 3px var(--color-accent-alpha);
42
+ }
43
+ }
44
+
45
+ [data-slot="chevron"] {
46
+ opacity: 0.6;
47
+ }
48
+
49
+ [data-slot="dropdown"] {
50
+ min-width: 200px;
51
+ max-height: 300px;
52
+ overflow-y: auto;
53
+ padding: var(--space-1);
54
+ }
55
+ }
56
+ }
57
+
58
+ [data-slot="month-picker"] {
59
+ display: flex;
60
+ align-items: center;
61
+ background-color: var(--color-bg);
62
+ border: 1px solid var(--color-border);
63
+ border-radius: var(--border-radius-sm);
64
+ padding: 0;
65
+ }
66
+
67
+ [data-slot="month-button"] {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ background: none;
72
+ border: none !important;
73
+ color: var(--color-text);
74
+ cursor: pointer;
75
+ padding: var(--space-2) var(--space-3);
76
+ border-radius: var(--border-radius-xs);
77
+ transition: background-color 0.2s;
78
+ line-height: 1;
79
+
80
+ &:hover {
81
+ background-color: var(--color-bg-hover);
82
+ }
83
+
84
+ svg {
85
+ display: block;
86
+ width: 16px;
87
+ height: 16px;
88
+ stroke-width: 2;
89
+ }
90
+ }
91
+
92
+ [data-slot="month-label"] {
93
+ font-size: var(--font-size-sm);
94
+ font-weight: 500;
95
+ color: var(--color-text);
96
+ line-height: 1.5;
97
+ min-width: 140px;
98
+ text-align: center;
99
+ white-space: nowrap;
100
+ }
101
+
102
+ [data-slot="model-item"] {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: var(--space-2);
106
+ padding: var(--space-2) var(--space-3);
107
+ cursor: pointer;
108
+ transition: background-color 0.2s;
109
+ font-size: var(--font-size-sm);
110
+ color: var(--color-text);
111
+ border: none !important;
112
+ background: none;
113
+ width: 100%;
114
+ text-align: left;
115
+ white-space: nowrap;
116
+
117
+ &:hover {
118
+ background: var(--color-bg-hover);
119
+ }
120
+
121
+ span {
122
+ flex: 1;
123
+ user-select: none;
124
+ }
125
+ }
126
+
127
+ [data-slot="chart-container"] {
128
+ padding: var(--space-6);
129
+ background: var(--color-bg-secondary);
130
+ border: 1px solid var(--color-border);
131
+ border-radius: var(--border-radius-sm);
132
+ height: 400px;
133
+ }
134
+
135
+ @media (max-width: 40rem) {
136
+ [data-slot="chart-container"] {
137
+ height: 300px;
138
+ padding: var(--space-4);
139
+ }
140
+
141
+ [data-component="empty-state"] {
142
+ height: 300px;
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,475 @@
1
+ import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@jonsoc/console-core/drizzle/index.js"
2
+ import { UsageTable } from "@jonsoc/console-core/schema/billing.sql.js"
3
+ import { KeyTable } from "@jonsoc/console-core/schema/key.sql.js"
4
+ import { UserTable } from "@jonsoc/console-core/schema/user.sql.js"
5
+ import { AuthTable } from "@jonsoc/console-core/schema/auth.sql.js"
6
+ import { useParams } from "@solidjs/router"
7
+ import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
8
+ import { createStore } from "solid-js/store"
9
+ import { withActor } from "~/context/auth.withActor"
10
+ import { Dropdown } from "~/component/dropdown"
11
+ import { IconChevronLeft, IconChevronRight } from "~/component/icon"
12
+ import styles from "./graph-section.module.css"
13
+ import {
14
+ Chart,
15
+ BarController,
16
+ BarElement,
17
+ CategoryScale,
18
+ LinearScale,
19
+ Tooltip,
20
+ Legend,
21
+ type ChartConfiguration,
22
+ } from "chart.js"
23
+
24
+ Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
25
+
26
+ async function getCosts(workspaceID: string, year: number, month: number) {
27
+ "use server"
28
+ return withActor(async () => {
29
+ const startDate = new Date(year, month, 1)
30
+ const endDate = new Date(year, month + 1, 0)
31
+ const usageData = await Database.use((tx) =>
32
+ tx
33
+ .select({
34
+ date: sql<string>`DATE(${UsageTable.timeCreated})`,
35
+ model: UsageTable.model,
36
+ totalCost: sum(UsageTable.cost),
37
+ keyId: UsageTable.keyID,
38
+ subscription: sql<boolean>`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
39
+ })
40
+ .from(UsageTable)
41
+ .where(
42
+ and(
43
+ eq(UsageTable.workspaceID, workspaceID),
44
+ gte(UsageTable.timeCreated, startDate),
45
+ lte(UsageTable.timeCreated, endDate),
46
+ ),
47
+ )
48
+ .groupBy(
49
+ sql`DATE(${UsageTable.timeCreated})`,
50
+ UsageTable.model,
51
+ UsageTable.keyID,
52
+ sql`COALESCE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan') = 'sub', false)`,
53
+ )
54
+ .then((x) =>
55
+ x.map((r) => ({
56
+ ...r,
57
+ totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
58
+ subscription: Boolean(r.subscription),
59
+ })),
60
+ ),
61
+ )
62
+
63
+ // Get unique key IDs from usage
64
+ const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
65
+
66
+ // Second query: get all existing keys plus any keys from usage
67
+ const keysData = await Database.use((tx) =>
68
+ tx
69
+ .select({
70
+ keyId: KeyTable.id,
71
+ keyName: KeyTable.name,
72
+ userEmail: AuthTable.subject,
73
+ timeDeleted: KeyTable.timeDeleted,
74
+ })
75
+ .from(KeyTable)
76
+ .innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
77
+ .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
78
+ .where(
79
+ and(
80
+ eq(KeyTable.workspaceID, workspaceID),
81
+ usageKeyIds.size > 0
82
+ ? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
83
+ : isNull(KeyTable.timeDeleted),
84
+ ),
85
+ )
86
+ .orderBy(AuthTable.subject, KeyTable.name),
87
+ )
88
+
89
+ return {
90
+ usage: usageData,
91
+ keys: keysData.map((key) => ({
92
+ id: key.keyId,
93
+ displayName:
94
+ key.timeDeleted !== null
95
+ ? `${key.userEmail} - ${key.keyName} (deleted)`
96
+ : `${key.userEmail} - ${key.keyName}`,
97
+ })),
98
+ }
99
+ }, workspaceID)
100
+ }
101
+
102
+ const MODEL_COLORS: Record<string, string> = {
103
+ "claude-sonnet-4-5": "#D4745C",
104
+ "claude-sonnet-4": "#E8B4A4",
105
+ "claude-opus-4": "#C8A098",
106
+ "claude-haiku-4-5": "#F0D8D0",
107
+ "claude-3-5-haiku": "#F8E8E0",
108
+ "gpt-5.1": "#4A90E2",
109
+ "gpt-5.1-codex": "#6BA8F0",
110
+ "gpt-5": "#7DB8F8",
111
+ "gpt-5-codex": "#9FCAFF",
112
+ "gpt-5-nano": "#B8D8FF",
113
+ "grok-code": "#8B5CF6",
114
+ "big-pickle": "#10B981",
115
+ "kimi-k2": "#F59E0B",
116
+ "qwen3-coder": "#EC4899",
117
+ "glm-4.6": "#14B8A6",
118
+ }
119
+
120
+ function getModelColor(model: string): string {
121
+ if (MODEL_COLORS[model]) return MODEL_COLORS[model]
122
+
123
+ const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
124
+ const hue = Math.abs(hash) % 360
125
+ return `hsl(${hue}, 50%, 65%)`
126
+ }
127
+
128
+ function formatDateLabel(dateStr: string): string {
129
+ const date = new Date()
130
+ const [y, m, d] = dateStr.split("-").map(Number)
131
+ date.setFullYear(y)
132
+ date.setMonth(m - 1)
133
+ date.setDate(d)
134
+ date.setHours(0, 0, 0, 0)
135
+ const month = date.toLocaleDateString("en-US", { month: "short" })
136
+ const day = date.getUTCDate().toString().padStart(2, "0")
137
+ return `${month} ${day}`
138
+ }
139
+
140
+ function addOpacityToColor(color: string, opacity: number): string {
141
+ if (color.startsWith("#")) {
142
+ const r = parseInt(color.slice(1, 3), 16)
143
+ const g = parseInt(color.slice(3, 5), 16)
144
+ const b = parseInt(color.slice(5, 7), 16)
145
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`
146
+ }
147
+ if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
148
+ return color
149
+ }
150
+
151
+ export function GraphSection() {
152
+ let canvasRef: HTMLCanvasElement | undefined
153
+ let chartInstance: Chart | undefined
154
+ const params = useParams()
155
+ const now = new Date()
156
+ const [store, setStore] = createStore({
157
+ data: null as Awaited<ReturnType<typeof getCosts>> | null,
158
+ year: now.getFullYear(),
159
+ month: now.getMonth(),
160
+ key: null as string | null,
161
+ model: null as string | null,
162
+ modelDropdownOpen: false,
163
+ keyDropdownOpen: false,
164
+ colorScheme: "light" as "light" | "dark",
165
+ })
166
+ const onPreviousMonth = async () => {
167
+ const month = store.month === 0 ? 11 : store.month - 1
168
+ const year = store.month === 0 ? store.year - 1 : store.year
169
+ setStore({ month, year })
170
+ }
171
+
172
+ const onNextMonth = async () => {
173
+ const month = store.month === 11 ? 0 : store.month + 1
174
+ const year = store.month === 11 ? store.year + 1 : store.year
175
+ setStore({ month, year })
176
+ }
177
+
178
+ const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
179
+
180
+ const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
181
+
182
+ const getModels = createMemo(() => {
183
+ if (!store.data?.usage) return []
184
+ return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
185
+ })
186
+
187
+ const getDates = createMemo(() => {
188
+ const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
189
+ return Array.from({ length: daysInMonth }, (_, i) => {
190
+ const date = new Date(store.year, store.month, i + 1)
191
+ return date.toISOString().split("T")[0]
192
+ })
193
+ })
194
+
195
+ const getKeyName = (keyID: string | null): string => {
196
+ if (!keyID || !store.data?.keys) return "All Keys"
197
+ const found = store.data.keys.find((k) => k.id === keyID)
198
+ return found?.displayName ?? "All Keys"
199
+ }
200
+
201
+ const formatMonthYear = () =>
202
+ new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
203
+
204
+ const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
205
+
206
+ const chartConfig = createMemo((): ChartConfiguration | null => {
207
+ const data = store.data
208
+ const dates = getDates()
209
+ if (!data?.usage?.length) return null
210
+
211
+ store.colorScheme
212
+ const styles = getComputedStyle(document.documentElement)
213
+ const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim()
214
+ const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim()
215
+ const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim()
216
+ const colorText = styles.getPropertyValue("--color-text").trim()
217
+ const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
218
+ const colorBorder = styles.getPropertyValue("--color-border").trim()
219
+
220
+ const dailyDataSub = new Map<string, Map<string, number>>()
221
+ const dailyDataNonSub = new Map<string, Map<string, number>>()
222
+ for (const dateKey of dates) {
223
+ dailyDataSub.set(dateKey, new Map())
224
+ dailyDataNonSub.set(dateKey, new Map())
225
+ }
226
+
227
+ data.usage
228
+ .filter((row) => (store.key ? row.keyId === store.key : true))
229
+ .forEach((row) => {
230
+ const targetMap = row.subscription ? dailyDataSub : dailyDataNonSub
231
+ const dayMap = targetMap.get(row.date)
232
+ if (!dayMap) return
233
+ dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
234
+ })
235
+
236
+ const filteredModels = store.model === null ? getModels() : [store.model]
237
+
238
+ // Create datasets: non-subscription first, then subscription (with hatched pattern effect via opacity)
239
+ const datasets = [
240
+ ...filteredModels
241
+ .filter((model) => dates.some((date) => (dailyDataNonSub.get(date)?.get(model) || 0) > 0))
242
+ .map((model) => {
243
+ const color = getModelColor(model)
244
+ return {
245
+ label: model,
246
+ data: dates.map((date) => (dailyDataNonSub.get(date)?.get(model) || 0) / 100_000_000),
247
+ backgroundColor: color,
248
+ hoverBackgroundColor: color,
249
+ borderWidth: 0,
250
+ stack: "usage",
251
+ }
252
+ }),
253
+ ...filteredModels
254
+ .filter((model) => dates.some((date) => (dailyDataSub.get(date)?.get(model) || 0) > 0))
255
+ .map((model) => {
256
+ const color = getModelColor(model)
257
+ return {
258
+ label: `${model} (sub)`,
259
+ data: dates.map((date) => (dailyDataSub.get(date)?.get(model) || 0) / 100_000_000),
260
+ backgroundColor: addOpacityToColor(color, 0.5),
261
+ hoverBackgroundColor: addOpacityToColor(color, 0.7),
262
+ borderWidth: 1,
263
+ borderColor: color,
264
+ stack: "subscription",
265
+ }
266
+ }),
267
+ ]
268
+
269
+ return {
270
+ type: "bar",
271
+ data: {
272
+ labels: dates.map(formatDateLabel),
273
+ datasets,
274
+ },
275
+ options: {
276
+ responsive: true,
277
+ maintainAspectRatio: false,
278
+ scales: {
279
+ x: {
280
+ stacked: true,
281
+ grid: {
282
+ display: false,
283
+ },
284
+ ticks: {
285
+ maxRotation: 0,
286
+ autoSkipPadding: 20,
287
+ color: colorTextMuted,
288
+ font: {
289
+ family: "monospace",
290
+ size: 11,
291
+ },
292
+ },
293
+ },
294
+ y: {
295
+ stacked: true,
296
+ beginAtZero: true,
297
+ grid: {
298
+ color: colorBorderMuted,
299
+ },
300
+ ticks: {
301
+ color: colorTextMuted,
302
+ font: {
303
+ family: "monospace",
304
+ size: 11,
305
+ },
306
+ callback: (value) => {
307
+ const num = Number(value)
308
+ return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
309
+ },
310
+ },
311
+ },
312
+ },
313
+ plugins: {
314
+ tooltip: {
315
+ mode: "index",
316
+ intersect: false,
317
+ backgroundColor: colorBgElevated,
318
+ titleColor: colorText,
319
+ bodyColor: colorTextSecondary,
320
+ borderColor: colorBorder,
321
+ borderWidth: 1,
322
+ padding: 12,
323
+ displayColors: true,
324
+ filter: (item) => (item.parsed.y ?? 0) > 0,
325
+ callbacks: {
326
+ label: (context) => `${context.dataset.label}: $${(context.parsed.y ?? 0).toFixed(2)}`,
327
+ },
328
+ },
329
+ legend: {
330
+ display: true,
331
+ position: "bottom",
332
+ labels: {
333
+ color: colorTextSecondary,
334
+ font: {
335
+ size: 12,
336
+ },
337
+ padding: 16,
338
+ boxWidth: 16,
339
+ boxHeight: 16,
340
+ usePointStyle: false,
341
+ },
342
+ onHover: (event, legendItem, legend) => {
343
+ const chart = legend.chart
344
+ chart.data.datasets?.forEach((dataset, i) => {
345
+ const meta = chart.getDatasetMeta(i)
346
+ const label = dataset.label || ""
347
+ const isSub = label.endsWith(" (sub)")
348
+ const model = isSub ? label.slice(0, -6) : label
349
+ const baseColor = getModelColor(model)
350
+ const originalColor = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
351
+ const color = i === legendItem.datasetIndex ? originalColor : addOpacityToColor(baseColor, 0.15)
352
+ meta.data.forEach((bar: any) => {
353
+ bar.options.backgroundColor = color
354
+ })
355
+ })
356
+ chart.update("none")
357
+ },
358
+ onLeave: (event, legendItem, legend) => {
359
+ const chart = legend.chart
360
+ chart.data.datasets?.forEach((dataset, i) => {
361
+ const meta = chart.getDatasetMeta(i)
362
+ const label = dataset.label || ""
363
+ const isSub = label.endsWith(" (sub)")
364
+ const model = isSub ? label.slice(0, -6) : label
365
+ const baseColor = getModelColor(model)
366
+ const color = isSub ? addOpacityToColor(baseColor, 0.5) : baseColor
367
+ meta.data.forEach((bar: any) => {
368
+ bar.options.backgroundColor = color
369
+ })
370
+ })
371
+ chart.update("none")
372
+ },
373
+ },
374
+ },
375
+ },
376
+ }
377
+ })
378
+
379
+ createEffect(async () => {
380
+ const data = await getCosts(params.id!, store.year, store.month)
381
+ setStore({ data })
382
+ })
383
+
384
+ createEffect(() => {
385
+ const config = chartConfig()
386
+ if (!config || !canvasRef) return
387
+
388
+ if (chartInstance) chartInstance.destroy()
389
+ chartInstance = new Chart(canvasRef, config)
390
+
391
+ onCleanup(() => chartInstance?.destroy())
392
+ })
393
+
394
+ createEffect(() => {
395
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
396
+ setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" })
397
+
398
+ const handleColorSchemeChange = (e: MediaQueryListEvent) => {
399
+ setStore({ colorScheme: e.matches ? "dark" : "light" })
400
+ }
401
+
402
+ mediaQuery.addEventListener("change", handleColorSchemeChange)
403
+ onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange))
404
+ })
405
+
406
+ return (
407
+ <section class={styles.root}>
408
+ <div data-slot="section-title">
409
+ <h2>Cost</h2>
410
+ <p>Usage costs broken down by model.</p>
411
+ </div>
412
+
413
+ <div data-slot="filter-container">
414
+ <div data-slot="month-picker">
415
+ <button data-slot="month-button" onClick={onPreviousMonth}>
416
+ <IconChevronLeft />
417
+ </button>
418
+ <span data-slot="month-label">{formatMonthYear()}</span>
419
+ <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
420
+ <IconChevronRight />
421
+ </button>
422
+ </div>
423
+ <Dropdown
424
+ trigger={store.model === null ? "All Models" : store.model}
425
+ open={store.modelDropdownOpen}
426
+ onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
427
+ >
428
+ <>
429
+ <button data-slot="model-item" onClick={() => onSelectModel(null)}>
430
+ <span>All Models</span>
431
+ </button>
432
+ <For each={getModels()}>
433
+ {(model) => (
434
+ <button data-slot="model-item" onClick={() => onSelectModel(model)}>
435
+ <span>{model}</span>
436
+ </button>
437
+ )}
438
+ </For>
439
+ </>
440
+ </Dropdown>
441
+ <Dropdown
442
+ trigger={getKeyName(store.key)}
443
+ open={store.keyDropdownOpen}
444
+ onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
445
+ >
446
+ <>
447
+ <button data-slot="model-item" onClick={() => onSelectKey(null)}>
448
+ <span>All Keys</span>
449
+ </button>
450
+ <For each={store.data?.keys || []}>
451
+ {(key) => (
452
+ <button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
453
+ <span>{key.displayName}</span>
454
+ </button>
455
+ )}
456
+ </For>
457
+ </>
458
+ </Dropdown>
459
+ </div>
460
+
461
+ <Show
462
+ when={chartConfig()}
463
+ fallback={
464
+ <div data-component="empty-state">
465
+ <p>No usage data available for the selected period.</p>
466
+ </div>
467
+ }
468
+ >
469
+ <div data-slot="chart-container">
470
+ <canvas ref={canvasRef} />
471
+ </div>
472
+ </Show>
473
+ </section>
474
+ )
475
+ }
@@ -0,0 +1,81 @@
1
+ import { Match, Show, Switch, createMemo } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
4
+ import { NewUserSection } from "./new-user-section"
5
+ import { UsageSection } from "./usage-section"
6
+ import { ModelSection } from "./model-section"
7
+ import { ProviderSection } from "./provider-section"
8
+ import { GraphSection } from "./graph-section"
9
+ import { IconLogo } from "~/component/icon"
10
+ import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
11
+
12
+ export default function () {
13
+ const params = useParams()
14
+ const userInfo = createAsync(() => querySessionInfo(params.id!))
15
+ const billingInfo = createAsync(() => queryBillingInfo(params.id!))
16
+ const checkoutAction = useAction(createCheckoutUrl)
17
+ const checkoutSubmission = useSubmission(createCheckoutUrl)
18
+ const [store, setStore] = createStore({
19
+ checkoutRedirecting: false,
20
+ })
21
+ const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
22
+
23
+ async function onClickCheckout() {
24
+ const baseUrl = window.location.href
25
+ const checkout = await checkoutAction(params.id!, billingInfo()!.reloadAmount, baseUrl, baseUrl)
26
+ if (checkout && checkout.data) {
27
+ setStore("checkoutRedirecting", true)
28
+ window.location.href = checkout.data
29
+ }
30
+ }
31
+
32
+ return (
33
+ <div data-page="workspace-[id]">
34
+ <section data-component="header-section">
35
+ <IconLogo />
36
+ <p>
37
+ <span>
38
+ Reliable optimized models for coding agents.{" "}
39
+ <a target="_blank" href="/docs/zen">
40
+ Learn more
41
+ </a>
42
+ .
43
+ </span>
44
+ <Show when={userInfo()?.isAdmin}>
45
+ <span data-slot="billing-info">
46
+ <Show
47
+ when={billingInfo()?.customerID}
48
+ fallback={
49
+ <button
50
+ data-color="primary"
51
+ data-size="sm"
52
+ disabled={checkoutSubmission.pending || store.checkoutRedirecting}
53
+ onClick={onClickCheckout}
54
+ >
55
+ {checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
56
+ </button>
57
+ }
58
+ >
59
+ <span data-slot="balance">
60
+ Current balance <b>${balance()}</b>
61
+ </span>
62
+ </Show>
63
+ </span>
64
+ </Show>
65
+ </p>
66
+ </section>
67
+
68
+ <div data-slot="sections">
69
+ <NewUserSection />
70
+ <Show when={userInfo()?.isAdmin}>
71
+ <GraphSection />
72
+ </Show>
73
+ <ModelSection />
74
+ <Show when={userInfo()?.isAdmin}>
75
+ <ProviderSection />
76
+ </Show>
77
+ <UsageSection />
78
+ </div>
79
+ </div>
80
+ )
81
+ }