Frank 2 месяцев назад
Родитель
Сommit
e03932e586

+ 55 - 1
packages/console/app/src/routes/workspace/[id]/billing/black-section.module.css

@@ -5,4 +5,58 @@
     align-items: center;
     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);
+  }
+}

+ 76 - 1
packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx

@@ -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 { Show } from "solid-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 { queryBillingInfo } from "../../common"
 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) => {
   "use server"
   return json(
@@ -26,6 +74,7 @@ export function BlackSection() {
   const params = useParams()
   const sessionAction = useAction(createSessionUrl)
   const sessionSubmission = useSubmission(createSessionUrl)
+  const subscription = createAsync(() => querySubscription(params.id!))
   const [store, setStore] = createStore({
     sessionRedirecting: false,
   })
@@ -53,6 +102,32 @@ export function BlackSection() {
           </button>
         </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>
   )
 }

+ 12 - 2
packages/console/app/src/routes/workspace/common.tsx

@@ -3,7 +3,6 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
 import { action, json, query } from "@solidjs/router"
 import { withActor } from "~/context/auth.withActor"
 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 { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.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 () => {
     const billing = await Billing.get()
     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,
       reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
       reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
       reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
+      monthlyLimit: billing.monthlyLimit,
+      monthlyUsage: billing.monthlyUsage,
+      timeMonthlyUsageUpdated: billing.timeMonthlyUsageUpdated,
+      reloadError: billing.reloadError,
+      timeReloadError: billing.timeReloadError,
+      subscriptionID: billing.subscriptionID,
     }
   }, workspaceID)
 }, "billing.get")

+ 15 - 14
packages/console/app/src/routes/zen/util/handler.ts

@@ -9,7 +9,7 @@ import { Billing } from "@opencode-ai/console-core/billing.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.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 { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
 import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
@@ -495,27 +495,28 @@ export async function handler(
 
       // Check weekly limit
       if (sub.fixedUsage && sub.timeFixedUpdated) {
-        const week = getWeekBounds(now)
-        if (sub.timeFixedUpdated >= week.start && sub.fixedUsage >= centsToMicroCents(black.fixedLimit * 100)) {
-          const retryAfter = Math.ceil((week.end.getTime() - now.getTime()) / 1000)
+        const result = Black.analyzeWeeklyUsage({
+          usage: sub.fixedUsage,
+          timeUpdated: sub.timeFixedUpdated,
+        })
+        if (result.status === "rate-limited")
           throw new SubscriptionError(
-            `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
-            retryAfter,
+            `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+            result.resetInSec,
           )
-        }
       }
 
       // Check rolling limit
       if (sub.rollingUsage && sub.timeRollingUpdated) {
-        const rollingWindowMs = black.rollingWindow * 3600 * 1000
-        const windowStart = new Date(now.getTime() - rollingWindowMs)
-        if (sub.timeRollingUpdated >= windowStart && sub.rollingUsage >= centsToMicroCents(black.rollingLimit * 100)) {
-          const retryAfter = Math.ceil((sub.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000)
+        const result = Black.analyzeRollingUsage({
+          usage: sub.rollingUsage,
+          timeUpdated: sub.timeRollingUpdated,
+        })
+        if (result.status === "rate-limited")
           throw new SubscriptionError(
-            `Subscription quota exceeded. Retry in ${formatRetryTime(retryAfter)}.`,
-            retryAfter,
+            `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
+            result.resetInSec,
           )
-        }
       }
 
       return

+ 1 - 16
packages/console/core/src/billing.ts

@@ -25,22 +25,7 @@ export namespace Billing {
   export const get = async () => {
     return Database.use(async (tx) =>
       tx
-        .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,
-        })
+        .select()
         .from(BillingTable)
         .where(eq(BillingTable.workspaceID, Actor.workspace()))
         .then((r) => r[0]),

+ 72 - 0
packages/console/core/src/black.ts

@@ -1,6 +1,8 @@
 import { z } from "zod"
 import { fn } from "./util/fn"
 import { Resource } from "@opencode-ai/console-resource"
+import { centsToMicroCents } from "./util/price"
+import { getWeekBounds } from "./util/date"
 
 export namespace BlackData {
   const Schema = z.object({
@@ -18,3 +20,73 @@ export namespace BlackData {
     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,
+      }
+    },
+  )
+}