zen: black usage
This commit is contained in:
@@ -5,4 +5,58 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="usage"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="usage-item"] {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="usage-header"] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="usage-label"] {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="usage-value"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="progress"] {
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--color-bg-surface);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="progress-bar"] {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="reset-time"] {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,58 @@
|
|||||||
import { action, useParams, useAction, useSubmission, json } from "@solidjs/router"
|
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Show } from "solid-js"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
|
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
|
import { SubscriptionTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
|
import { Black } from "@opencode-ai/console-core/black.js"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { queryBillingInfo } from "../../common"
|
import { queryBillingInfo } from "../../common"
|
||||||
import styles from "./black-section.module.css"
|
import styles from "./black-section.module.css"
|
||||||
|
|
||||||
|
const querySubscription = query(async (workspaceID: string) => {
|
||||||
|
"use server"
|
||||||
|
return withActor(async () => {
|
||||||
|
const row = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
rollingUsage: SubscriptionTable.rollingUsage,
|
||||||
|
fixedUsage: SubscriptionTable.fixedUsage,
|
||||||
|
timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
|
||||||
|
timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
|
||||||
|
})
|
||||||
|
.from(SubscriptionTable)
|
||||||
|
.where(and(eq(SubscriptionTable.workspaceID, Actor.workspace()), isNull(SubscriptionTable.timeDeleted)))
|
||||||
|
.then((r) => r[0]),
|
||||||
|
)
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollingUsage: Black.analyzeRollingUsage({
|
||||||
|
usage: row.rollingUsage ?? 0,
|
||||||
|
timeUpdated: row.timeRollingUpdated ?? new Date(),
|
||||||
|
}),
|
||||||
|
weeklyUsage: Black.analyzeWeeklyUsage({
|
||||||
|
usage: row.fixedUsage ?? 0,
|
||||||
|
timeUpdated: row.timeFixedUpdated ?? new Date(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}, workspaceID)
|
||||||
|
}, "subscription.get")
|
||||||
|
|
||||||
|
function formatResetTime(seconds: number) {
|
||||||
|
const days = Math.floor(seconds / 86400)
|
||||||
|
if (days >= 1) {
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
return `${days} ${days === 1 ? "day" : "days"} ${hours} ${hours === 1 ? "hour" : "hours"}`
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (hours >= 1) return `${hours} ${hours === 1 ? "hour" : "hours"} ${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
||||||
|
if (minutes === 0) return "a few seconds"
|
||||||
|
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`
|
||||||
|
}
|
||||||
|
|
||||||
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
|
||||||
"use server"
|
"use server"
|
||||||
return json(
|
return json(
|
||||||
@@ -26,6 +74,7 @@ export function BlackSection() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sessionAction = useAction(createSessionUrl)
|
const sessionAction = useAction(createSessionUrl)
|
||||||
const sessionSubmission = useSubmission(createSessionUrl)
|
const sessionSubmission = useSubmission(createSessionUrl)
|
||||||
|
const subscription = createAsync(() => querySubscription(params.id!))
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
sessionRedirecting: false,
|
sessionRedirecting: false,
|
||||||
})
|
})
|
||||||
@@ -53,6 +102,32 @@ export function BlackSection() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={subscription()}>
|
||||||
|
{(sub) => (
|
||||||
|
<div data-slot="usage">
|
||||||
|
<div data-slot="usage-item">
|
||||||
|
<div data-slot="usage-header">
|
||||||
|
<span data-slot="usage-label">5-hour Usage</span>
|
||||||
|
<span data-slot="usage-value">{sub().rollingUsage.usagePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div data-slot="progress">
|
||||||
|
<div data-slot="progress-bar" style={{ width: `${sub().rollingUsage.usagePercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<span data-slot="reset-time">Resets in {formatResetTime(sub().rollingUsage.resetInSec)}</span>
|
||||||
|
</div>
|
||||||
|
<div data-slot="usage-item">
|
||||||
|
<div data-slot="usage-header">
|
||||||
|
<span data-slot="usage-label">Weekly Usage</span>
|
||||||
|
<span data-slot="usage-value">{sub().weeklyUsage.usagePercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div data-slot="progress">
|
||||||
|
<div data-slot="progress-bar" style={{ width: `${sub().weeklyUsage.usagePercent}%` }} />
|
||||||
|
</div>
|
||||||
|
<span data-slot="reset-time">Resets in {formatResetTime(sub().weeklyUsage.resetInSec)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
|||||||
import { action, json, query } from "@solidjs/router"
|
import { action, json, query } from "@solidjs/router"
|
||||||
import { withActor } from "~/context/auth.withActor"
|
import { withActor } from "~/context/auth.withActor"
|
||||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
import { User } from "@opencode-ai/console-core/user.js"
|
|
||||||
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
import { and, Database, desc, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
@@ -96,11 +95,22 @@ export const queryBillingInfo = query(async (workspaceID: string) => {
|
|||||||
return withActor(async () => {
|
return withActor(async () => {
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
return {
|
return {
|
||||||
...billing,
|
customerID: billing.customerID,
|
||||||
|
paymentMethodID: billing.paymentMethodID,
|
||||||
|
paymentMethodType: billing.paymentMethodType,
|
||||||
|
paymentMethodLast4: billing.paymentMethodLast4,
|
||||||
|
balance: billing.balance,
|
||||||
|
reload: billing.reload,
|
||||||
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
|
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
|
||||||
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
|
||||||
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
|
||||||
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
|
||||||
|
monthlyLimit: billing.monthlyLimit,
|
||||||
|
monthlyUsage: billing.monthlyUsage,
|
||||||
|
timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
|
||||||
|
reloadError: billing.reloadError,
|
||||||
|
timeReloadError: billing.timeReloadError,
|
||||||
|
subscriptionID: billing.subscriptionID,
|
||||||
}
|
}
|
||||||
}, workspaceID)
|
}, workspaceID)
|
||||||
}, "billing.get")
|
}, "billing.get")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
|
|||||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
import { Black, BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||||
@@ -495,27 +495,28 @@ export async function handler(
|
|||||||
|
|
||||||
// Check weekly limit
|
// Check weekly limit
|
||||||
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
if (sub.fixedUsage && sub.timeFixedUpdated) {
|
||||||
const week = getWeekBounds(now)
|
const result = Black.analyzeWeeklyUsage({
|
||||||
if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
|
usage: sub.fixedUsage,
|
||||||
const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
|
timeUpdated: sub.timeFixedUpdated,
|
||||||
|
})
|
||||||
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||||
retryAfter,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rolling limit
|
// Check rolling limit
|
||||||
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
if (sub.rollingUsage && sub.timeRollingUpdated) {
|
||||||
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
const result = Black.analyzeRollingUsage({
|
||||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
usage: sub.rollingUsage,
|
||||||
if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
|
timeUpdated: sub.timeRollingUpdated,
|
||||||
const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
|
})
|
||||||
|
if (result.status === "rate-limited")
|
||||||
throw new SubscriptionError(
|
throw new SubscriptionError(
|
||||||
`Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
|
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
||||||
retryAfter,
|
result.resetInSec,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -25,22 +25,7 @@ export namespace Billing {
|
|||||||
export const get = async () => {
|
export const get = async () => {
|
||||||
return Database.use(async (tx) =>
|
return Database.use(async (tx) =>
|
||||||
tx
|
tx
|
||||||
.select({
|
.select()
|
||||||
customerID: BillingTable.customerID,
|
|
||||||
subscriptionID: BillingTable.subscriptionID,
|
|
||||||
paymentMethodID: BillingTable.paymentMethodID,
|
|
||||||
paymentMethodType: BillingTable.paymentMethodType,
|
|
||||||
paymentMethodLast4: BillingTable.paymentMethodLast4,
|
|
||||||
balance: BillingTable.balance,
|
|
||||||
reload: BillingTable.reload,
|
|
||||||
reloadAmount: BillingTable.reloadAmount,
|
|
||||||
reloadTrigger: BillingTable.reloadTrigger,
|
|
||||||
monthlyLimit: BillingTable.monthlyLimit,
|
|
||||||
monthlyUsage: BillingTable.monthlyUsage,
|
|
||||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
|
||||||
reloadError: BillingTable.reloadError,
|
|
||||||
timeReloadError: BillingTable.timeReloadError,
|
|
||||||
})
|
|
||||||
.from(BillingTable)
|
.from(BillingTable)
|
||||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||||
.then((r) => r[0]),
|
.then((r) => r[0]),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
|
import { centsToMicroCents } from "./util/price"
|
||||||
|
import { getWeekBounds } from "./util/date"
|
||||||
|
|
||||||
export namespace BlackData {
|
export namespace BlackData {
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -18,3 +20,73 @@ export namespace BlackData {
|
|||||||
return Schema.parse(json)
|
return Schema.parse(json)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace Black {
|
||||||
|
export const analyzeRollingUsage = fn(
|
||||||
|
z.object({
|
||||||
|
usage: z.number().int(),
|
||||||
|
timeUpdated: z.date(),
|
||||||
|
}),
|
||||||
|
({ usage, timeUpdated }) => {
|
||||||
|
const now = new Date()
|
||||||
|
const black = BlackData.get()
|
||||||
|
const rollingWindowMs = black.rollingWindow * 3600 * 1000
|
||||||
|
const rollingLimitInMicroCents = centsToMicroCents(black.rollingLimit * 100)
|
||||||
|
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||||
|
if (timeUpdated < windowStart) {
|
||||||
|
return {
|
||||||
|
status: "ok" as const,
|
||||||
|
resetInSec: black.rollingWindow * 3600,
|
||||||
|
usagePercent: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs)
|
||||||
|
if (usage < rollingLimitInMicroCents) {
|
||||||
|
return {
|
||||||
|
status: "ok" as const,
|
||||||
|
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||||
|
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "rate-limited" as const,
|
||||||
|
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||||
|
usagePercent: 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const analyzeWeeklyUsage = fn(
|
||||||
|
z.object({
|
||||||
|
usage: z.number().int(),
|
||||||
|
timeUpdated: z.date(),
|
||||||
|
}),
|
||||||
|
({ usage, timeUpdated }) => {
|
||||||
|
const black = BlackData.get()
|
||||||
|
const now = new Date()
|
||||||
|
const week = getWeekBounds(now)
|
||||||
|
const fixedLimitInMicroCents = centsToMicroCents(black.fixedLimit * 100)
|
||||||
|
if (timeUpdated < week.start) {
|
||||||
|
return {
|
||||||
|
status: "ok" as const,
|
||||||
|
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||||
|
usagePercent: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (usage < fixedLimitInMicroCents) {
|
||||||
|
return {
|
||||||
|
status: "ok" as const,
|
||||||
|
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||||
|
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "rate-limited" as const,
|
||||||
|
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||||
|
usagePercent: 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user