diff --git a/bun.lock b/bun.lock index bf44bd581d..a9e11c0450 100644 --- a/bun.lock +++ b/bun.lock @@ -192,6 +192,26 @@ "cloudflare": "5.2.0", }, }, + "packages/console/support": { + "name": "@opencode-ai/console-support", + "version": "0.0.0", + "dependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@opencode-ai/console-core": "workspace:*", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "nitro": "3.0.1-alpha.1", + "solid-js": "catalog:", + "vite": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "wrangler": "4.50.0", + }, + }, "packages/core": { "name": "@opencode-ai/core", "version": "1.15.10", @@ -1631,6 +1651,8 @@ "@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"], + "@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"], + "@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], diff --git a/packages/console/support/package.json b/packages/console/support/package.json new file mode 100644 index 0000000000..8120b196e7 --- /dev/null +++ b/packages/console/support/package.json @@ -0,0 +1,29 @@ +{ + "name": "@opencode-ai/console-support", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "scripts": { + "typecheck": "tsgo --noEmit", + "dev": "sst shell --stage production -- vite dev" + }, + "dependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@opencode-ai/console-core": "workspace:*", + "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", + "@solidjs/start": "catalog:", + "nitro": "3.0.1-alpha.1", + "solid-js": "catalog:", + "vite": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + "wrangler": "4.50.0" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/console/support/src/app.css b/packages/console/support/src/app.css new file mode 100644 index 0000000000..895d8091ab --- /dev/null +++ b/packages/console/support/src/app.css @@ -0,0 +1,133 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + background: #0d0d0d; + color: #e6e6e6; +} + +main[data-page="support"] { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +main[data-page="support"] h1 { + margin: 0 0 1rem; + font-size: 1.25rem; + font-weight: 500; +} + +form[data-component="lookup"] { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + align-items: center; + flex-wrap: wrap; +} + +form[data-component="lookup"] input[type="text"] { + flex: 1; + min-width: 280px; + padding: 0.6rem 0.75rem; + border: 1px solid #2a2a2a; + background: #161616; + color: #e6e6e6; + border-radius: 4px; + font: inherit; +} + +form[data-component="lookup"] label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: #b0b0b0; +} + +form[data-component="lookup"] button { + padding: 0.6rem 1rem; + border: 1px solid #3a3a3a; + background: #1f1f1f; + color: #e6e6e6; + border-radius: 4px; + font: inherit; + cursor: pointer; +} + +form[data-component="lookup"] button:hover { + background: #2a2a2a; +} + +form[data-component="lookup"] button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +[data-component="error"] { + color: #ff6b6b; + background: #2a1414; + border: 1px solid #4a1f1f; + padding: 0.75rem 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +[data-component="section"] { + margin-bottom: 2rem; +} + +[data-component="section"] h2 { + margin: 0 0 0.5rem; + font-size: 1rem; + font-weight: 500; + color: #b0b0b0; + border-bottom: 1px solid #2a2a2a; + padding-bottom: 0.4rem; +} + +[data-component="section"] h3 { + margin: 1.75rem 0 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: #888; +} + +[data-component="section"] h3:first-of-type { + margin-top: 0; +} + +[data-component="section"] table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +[data-component="section"] th, +[data-component="section"] td { + text-align: left; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid #1f1f1f; + vertical-align: top; + white-space: nowrap; +} + +[data-component="section"] th { + color: #888; + font-weight: 500; + background: #141414; +} + +[data-component="section"] td a { + color: #6ea8fe; +} + +[data-component="section"] [data-empty] { + color: #666; + font-style: italic; + padding: 0.4rem 0; +} diff --git a/packages/console/support/src/app.tsx b/packages/console/support/src/app.tsx new file mode 100644 index 0000000000..9ae229d04b --- /dev/null +++ b/packages/console/support/src/app.tsx @@ -0,0 +1,21 @@ +import { MetaProvider, Title } from "@solidjs/meta" +import { Router } from "@solidjs/router" +import { FileRoutes } from "@solidjs/start/router" +import { Suspense } from "solid-js" +import "./app.css" + +export default function App() { + return ( + ( + + opencode support + {props.children} + + )} + > + + + ) +} diff --git a/packages/console/support/src/component/result.tsx b/packages/console/support/src/component/result.tsx new file mode 100644 index 0000000000..3800a2c95e --- /dev/null +++ b/packages/console/support/src/component/result.tsx @@ -0,0 +1,119 @@ +import { For, Show } from "solid-js" +import type { LookupResult, WorkspaceSection } from "~/lib/lookup" + +export function Result(props: { data: LookupResult }) { + return ( + <> + + {(auth) => ( +
+

Auth

+ +
+ )} +
+ + + {(workspaces) => ( +
+

Workspaces

+ +
+ )} +
+ + {(ws) => } + + ) +} + +function WorkspaceView(props: { section: WorkspaceSection }) { + return ( +
+

{props.section.title}

+ +

Users

+ + +

Billing

+ + +

GO

+ + +

Payments

+ + +

28-Day Usage

+ + +

Disabled Models

+ +
+ ) +} + +function DataTable(props: { rows: Record[] }) { + const columns = () => { + const cols = new Set() + for (const row of props.rows) { + for (const key of Object.keys(row)) cols.add(key) + } + return [...cols] + } + + return ( + 0} fallback={
(no data)
}> + + + + {(col) => } + + + + + {(row) => ( + + {(col) => } + + )} + + +
{col}
{renderCell(row[col])}
+
+ ) +} + +function renderCell(value: unknown) { + if (value === null || value === undefined) return "" + if (typeof value === "string" && value.startsWith("https://")) { + return ( + + {value} + + ) + } + if (isLinkCell(value)) { + const external = value.__link.startsWith("http") + return ( + + {value.label} + + ) + } + if (typeof value === "object") return JSON.stringify(value) + return String(value) +} + +function isLinkCell(value: unknown): value is { __link: string; label: string } { + return ( + typeof value === "object" && + value !== null && + "__link" in value && + typeof (value as { __link: unknown }).__link === "string" + ) +} diff --git a/packages/console/support/src/entry-client.tsx b/packages/console/support/src/entry-client.tsx new file mode 100644 index 0000000000..642deacf73 --- /dev/null +++ b/packages/console/support/src/entry-client.tsx @@ -0,0 +1,4 @@ +// @refresh reload +import { mount, StartClient } from "@solidjs/start/client" + +mount(() => , document.getElementById("app")!) diff --git a/packages/console/support/src/entry-server.tsx b/packages/console/support/src/entry-server.tsx new file mode 100644 index 0000000000..752f8522f7 --- /dev/null +++ b/packages/console/support/src/entry-server.tsx @@ -0,0 +1,26 @@ +// @refresh reload +import { createHandler, StartServer } from "@solidjs/start/server" + +export default createHandler( + () => ( + ( + + + + + + {assets} + + +
{children}
+ {scripts} + + + )} + /> + ), + { + mode: "async", + }, +) diff --git a/packages/console/support/src/global.d.ts b/packages/console/support/src/global.d.ts new file mode 100644 index 0000000000..dc6f10c226 --- /dev/null +++ b/packages/console/support/src/global.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/console/support/src/lib/lookup.ts b/packages/console/support/src/lib/lookup.ts new file mode 100644 index 0000000000..14c3978bf1 --- /dev/null +++ b/packages/console/support/src/lib/lookup.ts @@ -0,0 +1,480 @@ +"use server" + +import { Database, and, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js" +import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" +import { + BillingTable, + PaymentTable, + SubscriptionTable, + BlackPlans, + UsageTable, + LiteTable, +} from "@opencode-ai/console-core/schema/billing.sql.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" +import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" +import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" +import { BlackData } from "@opencode-ai/console-core/black.js" +import { LiteData } from "@opencode-ai/console-core/lite.js" +import { Subscription } from "@opencode-ai/console-core/subscription.js" +import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js" +import { getWeekBounds } from "@opencode-ai/console-core/util/date.js" + +export type LookupResult = { + identifier: string + auth?: Record[] + accountWorkspaces?: Record[] + workspaces: WorkspaceSection[] +} + +export type WorkspaceSection = { + workspaceID: string + title: string + users: Record[] + billing: Record | null + go: Record[] + payments: Record[] + usage: Record[] + disabledModels: Record[] +} + +export async function lookup(identifier: string): Promise { + if (!identifier) throw new Error("Identifier is required") + + if (identifier.startsWith("wrk_")) { + const workspace = await loadWorkspace(identifier) + return { identifier, workspaces: [workspace] } + } + + if (identifier.startsWith("key_")) { + const key = await Database.use((tx) => + tx + .select() + .from(KeyTable) + .where(eq(KeyTable.id, identifier)) + .then((rows) => rows[0]), + ) + if (!key) throw new Error("API key not found") + const workspace = await loadWorkspace(key.workspaceID) + return { identifier, workspaces: [workspace] } + } + + if (identifier.startsWith("sk-")) { + const key = await Database.use((tx) => + tx + .select() + .from(KeyTable) + .where(eq(KeyTable.key, identifier)) + .then((rows) => rows[0]), + ) + if (!key) throw new Error("API key not found") + const workspace = await loadWorkspace(key.workspaceID) + return { identifier, workspaces: [workspace] } + } + + // Treat as email + const authData = await Database.use((tx) => + tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)), + ) + if (authData.length === 0) throw new Error("Email not found") + + const accountID = authData[0].accountID + const auth = await Database.use((tx) => + tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)), + ) + + const accountWorkspaces = await Database.use((tx) => + tx + .select({ + userID: UserTable.id, + workspaceID: UserTable.workspaceID, + workspaceName: WorkspaceTable.name, + balance: BillingTable.balance, + role: UserTable.role, + black: SubscriptionTable.timeCreated, + lite: LiteTable.timeCreated, + }) + .from(UserTable) + .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) + .leftJoin(BillingTable, eq(BillingTable.workspaceID, WorkspaceTable.id)) + .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) + .leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id)) + .where(eq(UserTable.accountID, accountID)) + .then((rows) => + rows.map((row) => ({ + workspaceName: row.workspaceID + ? { __link: `#workspace-${row.workspaceID}`, label: row.workspaceName } + : row.workspaceName, + userID: row.userID, + workspaceID: row.workspaceID, + balance: formatMicroCents(row.balance) ?? "$0.00", + role: row.role, + black: formatDate(row.black), + lite: formatDate(row.lite), + })), + ), + ) + + const workspaces: WorkspaceSection[] = [] + for (const w of accountWorkspaces) { + if (!w.workspaceID) continue + workspaces.push(await loadWorkspace(w.workspaceID)) + } + + return { + identifier, + auth: auth.map((row) => ({ + provider: row.provider, + subject: row.subject, + accountID: row.accountID, + })), + accountWorkspaces, + workspaces, + } +} + +async function loadWorkspace(workspaceID: string): Promise { + const workspace = await Database.use((tx) => + tx + .select() + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, workspaceID)) + .then((rows) => rows[0]), + ) + if (!workspace) throw new Error(`Workspace ${workspaceID} not found`) + + const users = await Database.use((tx) => + tx + .select({ + authEmail: AuthTable.subject, + inviteEmail: UserTable.email, + role: UserTable.role, + timeSeen: UserTable.timeSeen, + monthlyLimit: UserTable.monthlyLimit, + monthlyUsage: UserTable.monthlyUsage, + timeDeleted: UserTable.timeDeleted, + fixedUsage: SubscriptionTable.fixedUsage, + rollingUsage: SubscriptionTable.rollingUsage, + timeFixedUpdated: SubscriptionTable.timeFixedUpdated, + timeRollingUpdated: SubscriptionTable.timeRollingUpdated, + timeSubscriptionCreated: SubscriptionTable.timeCreated, + subscription: BillingTable.subscription, + }) + .from(UserTable) + .innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id)) + .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email"))) + .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) + .where(eq(UserTable.workspaceID, workspace.id)) + .then((rows) => + rows.map((row) => { + const subStatus = getSubscriptionStatus(row) + return { + email: (row.timeDeleted ? "[deleted] " : "") + (row.authEmail ?? row.inviteEmail), + role: row.role, + timeSeen: formatDate(row.timeSeen), + monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit), + subscribed: formatDate(row.timeSubscriptionCreated), + subWeekly: subStatus.weekly, + subRolling: subStatus.rolling, + rateLimited: subStatus.rateLimited, + retryIn: subStatus.retryIn, + } + }), + ), + ) + + const billing = await Database.use((tx) => + tx + .select({ + balance: BillingTable.balance, + customerID: BillingTable.customerID, + reload: BillingTable.reload, + blackSubscriptionID: BillingTable.subscriptionID, + blackSubscription: { + plan: BillingTable.subscriptionPlan, + booked: BillingTable.timeSubscriptionBooked, + enrichment: BillingTable.subscription, + }, + timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected, + liteSubscriptionID: BillingTable.liteSubscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspace.id)) + .then( + (rows) => + rows.map((row) => ({ + balance: `$${(row.balance / 100000000).toFixed(2)}`, + reload: row.reload ? "yes" : "no", + customerID: row.customerID, + GO: row.liteSubscriptionID, + Black: row.blackSubscriptionID + ? [ + `Black ${row.blackSubscription.enrichment!.plan}`, + row.blackSubscription.enrichment!.seats > 1 + ? `X ${row.blackSubscription.enrichment!.seats} seats` + : "", + row.blackSubscription.enrichment!.coupon + ? `(coupon: ${row.blackSubscription.enrichment!.coupon})` + : "", + `(ref: ${row.blackSubscriptionID})`, + ].join(" ") + : row.blackSubscription.booked + ? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}` + : undefined, + }))[0] ?? null, + ), + ) + + const liteLimits = LiteData.getLimits() + const go = await Database.use((tx) => + tx + .select({ + userID: LiteTable.userID, + userEmail: UserTable.email, + authEmail: AuthTable.subject, + rollingUsage: LiteTable.rollingUsage, + weeklyUsage: LiteTable.weeklyUsage, + monthlyUsage: LiteTable.monthlyUsage, + timeRollingUpdated: LiteTable.timeRollingUpdated, + timeWeeklyUpdated: LiteTable.timeWeeklyUpdated, + timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, + timeCreated: LiteTable.timeCreated, + useBalance: BillingTable.lite, + }) + .from(LiteTable) + .innerJoin(BillingTable, eq(BillingTable.workspaceID, LiteTable.workspaceID)) + .leftJoin(UserTable, eq(UserTable.id, LiteTable.userID)) + .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email"))) + .where(and(eq(LiteTable.workspaceID, workspace.id), isNull(LiteTable.timeDeleted))) + .then((rows) => + rows.map((row) => { + const rolling = Subscription.analyzeRollingUsage({ + limit: liteLimits.rollingLimit, + window: liteLimits.rollingWindow, + usage: row.rollingUsage ?? 0, + timeUpdated: row.timeRollingUpdated ?? new Date(), + }) + const weekly = Subscription.analyzeWeeklyUsage({ + limit: liteLimits.weeklyLimit, + usage: row.weeklyUsage ?? 0, + timeUpdated: row.timeWeeklyUpdated ?? new Date(), + }) + const monthly = Subscription.analyzeMonthlyUsage({ + limit: liteLimits.monthlyLimit, + usage: row.monthlyUsage ?? 0, + timeUpdated: row.timeMonthlyUpdated ?? new Date(), + timeSubscribed: row.timeCreated, + }) + return { + email: row.authEmail ?? row.userEmail ?? row.userID, + subscribed: formatDate(row.timeCreated), + useBalance: row.useBalance?.useBalance ? "yes" : "no", + rolling: formatLiteUsage(rolling), + weekly: formatLiteUsage(weekly), + monthly: formatLiteUsage(monthly), + } + }), + ), + ) + + const payments = await Database.use((tx) => + tx + .select({ + amount: PaymentTable.amount, + paymentID: PaymentTable.paymentID, + invoiceID: PaymentTable.invoiceID, + customerID: PaymentTable.customerID, + timeCreated: PaymentTable.timeCreated, + timeRefunded: PaymentTable.timeRefunded, + }) + .from(PaymentTable) + .where(eq(PaymentTable.workspaceID, workspace.id)) + .orderBy(sql`${PaymentTable.timeCreated} DESC`) + .limit(100) + .then((rows) => + rows.map((row) => ({ + amount: `$${(row.amount / 100000000).toFixed(2)}`, + paymentID: row.paymentID + ? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}` + : null, + invoiceID: row.invoiceID, + customerID: row.customerID, + timeCreated: formatDate(row.timeCreated), + timeRefunded: formatDate(row.timeRefunded), + })), + ), + ) + + const planExpr = sql`JSON_UNQUOTE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan'))` + const usage = await Database.use((tx) => + tx + .select({ + date: sql`DATE(${UsageTable.timeCreated})`.as("date"), + freeRequests: sql`SUM(CASE WHEN ${UsageTable.cost} = 0 THEN 1 ELSE 0 END)`.as("free_requests"), + goRequests: sql`SUM(CASE WHEN ${planExpr} = 'lite' THEN 1 ELSE 0 END)`.as("go_requests"), + goCost: sql`SUM(CASE WHEN ${planExpr} = 'lite' THEN ${UsageTable.cost} ELSE 0 END)`.as("go_cost"), + apiRequests: sql`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN 1 ELSE 0 END)`.as("api_requests"), + apiCost: sql`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN ${UsageTable.cost} ELSE 0 END)`.as("api_cost"), + }) + .from(UsageTable) + .where( + and( + eq(UsageTable.workspaceID, workspace.id), + sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`, + ), + ) + .groupBy(sql`DATE(${UsageTable.timeCreated})`) + .orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`) + .then((rows) => { + const totals = rows.reduce( + (acc, r) => ({ + freeRequests: acc.freeRequests + Number(r.freeRequests), + goRequests: acc.goRequests + Number(r.goRequests), + goCost: acc.goCost + Number(r.goCost), + apiRequests: acc.apiRequests + Number(r.apiRequests), + apiCost: acc.apiCost + Number(r.apiCost), + }), + { freeRequests: 0, goRequests: 0, goCost: 0, apiRequests: 0, apiCost: 0 }, + ) + const mapped: Record[] = rows.map((row) => ({ + date: row.date, + freeRequests: Number(row.freeRequests), + goRequests: Number(row.goRequests), + goCost: formatMicroCents(Number(row.goCost)) ?? "$0.00", + apiRequests: Number(row.apiRequests), + apiCost: formatMicroCents(Number(row.apiCost)) ?? "$0.00", + })) + if (mapped.length > 0) { + mapped.push({ + date: "TOTAL", + freeRequests: totals.freeRequests, + goRequests: totals.goRequests, + goCost: formatMicroCents(totals.goCost) ?? "$0.00", + apiRequests: totals.apiRequests, + apiCost: formatMicroCents(totals.apiCost) ?? "$0.00", + }) + } + return mapped + }), + ) + + const disabledModels = await Database.use((tx) => + tx + .select({ + model: ModelTable.model, + timeCreated: ModelTable.timeCreated, + }) + .from(ModelTable) + .where(eq(ModelTable.workspaceID, workspace.id)) + .orderBy(sql`${ModelTable.timeCreated} DESC`) + .then((rows) => + rows.map((row) => ({ + model: row.model, + timeCreated: formatDate(row.timeCreated), + })), + ), + ) + + return { + workspaceID: workspace.id, + title: `Workspace "${workspace.name}" (${workspace.id})`, + users, + billing, + go, + payments, + usage, + disabledModels, + } +} + +function formatLiteUsage(usage: { status: "ok" | "rate-limited"; usagePercent: number; resetInSec: number }) { + const reset = formatResetTime(usage.resetInSec) + const status = usage.status === "rate-limited" ? " [limited]" : "" + return `${usage.usagePercent}% (resets in ${reset})${status}` +} + +function formatResetTime(seconds: number) { + if (seconds <= 0) return "now" + const days = Math.floor(seconds / 86400) + if (days >= 1) return `${days}d` + const hours = Math.floor(seconds / 3600) + if (hours >= 1) { + const minutes = Math.floor((seconds % 3600) / 60) + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h` + } + const minutes = Math.max(1, Math.ceil(seconds / 60)) + return `${minutes}m` +} + +function formatMicroCents(value: number | null | undefined) { + if (value === null || value === undefined) return null + return `$${(value / 100000000).toFixed(2)}` +} + +function formatDate(value: Date | null | undefined) { + if (!value) return null + return value.toISOString().split("T")[0] +} + +function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) { + const usageText = formatMicroCents(usage) ?? "$0.00" + if (limit === null || limit === undefined) return `${usageText} / no limit` + return `${usageText} / $${limit.toFixed(2)}` +} + +function formatRetryTime(seconds: number) { + const days = Math.floor(seconds / 86400) + if (days >= 1) return `${days} day${days > 1 ? "s" : ""}` + const hours = Math.floor(seconds / 3600) + const minutes = Math.ceil((seconds % 3600) / 60) + if (hours >= 1) return `${hours}hr ${minutes}min` + return `${minutes}min` +} + +function getSubscriptionStatus(row: { + subscription: { + plan: (typeof BlackPlans)[number] + } | null + timeSubscriptionCreated: Date | null + fixedUsage: number | null + rollingUsage: number | null + timeFixedUpdated: Date | null + timeRollingUpdated: Date | null +}) { + if (!row.timeSubscriptionCreated || !row.subscription) { + return { weekly: null, rolling: null, rateLimited: null, retryIn: null } + } + + const black = BlackData.getLimits({ plan: row.subscription.plan }) + const now = new Date() + const week = getWeekBounds(now) + + const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null + const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null + const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000 + + const currentWeekly = + row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0 + + const windowStart = new Date(now.getTime() - rollingWindowMs) + const currentRolling = + row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0 + + const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit + const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit + + const retryIn = isWeeklyLimited + ? formatRetryTime(Math.ceil((week.end.getTime() - now.getTime()) / 1000)) + : isRollingLimited && row.timeRollingUpdated + ? formatRetryTime( + Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000), + ) + : null + + return { + weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null, + rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null, + rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no", + retryIn, + } +} diff --git a/packages/console/support/src/routes/index.tsx b/packages/console/support/src/routes/index.tsx new file mode 100644 index 0000000000..8038788ce3 --- /dev/null +++ b/packages/console/support/src/routes/index.tsx @@ -0,0 +1,22 @@ +import { Title } from "@solidjs/meta" + +export default function SupportPage() { + return ( +
+ opencode support — lookup user +

Lookup user

+ +
+ + +
+
+ ) +} diff --git a/packages/console/support/src/routes/lookup.tsx b/packages/console/support/src/routes/lookup.tsx new file mode 100644 index 0000000000..7e977b7f95 --- /dev/null +++ b/packages/console/support/src/routes/lookup.tsx @@ -0,0 +1,39 @@ +import { Title } from "@solidjs/meta" +import { createAsync, query, useSearchParams, type RouteDefinition } from "@solidjs/router" +import { Show } from "solid-js" +import { ErrorBoundary } from "solid-js" +import { Result } from "~/component/result" +import { lookup } from "~/lib/lookup" + +const getLookup = query(async (identifier: string) => { + "use server" + return lookup(identifier) +}, "support.lookup") + +export const route: RouteDefinition = { + preload: ({ location }) => { + const identifier = new URLSearchParams(location.search).get("identifier")?.trim() + if (identifier) void getLookup(identifier) + }, +} + +export default function LookupPage() { + const [params] = useSearchParams() + const identifier = () => String(params.identifier ?? "").trim() + const data = createAsync(() => (identifier() ? getLookup(identifier()) : Promise.resolve(undefined))) + + return ( +
+ opencode support — {identifier() || "lookup"} +

Lookup: {identifier() || "(no identifier)"}

+ + Provide an `identifier` query parameter.}> +
{(err as Error).message}
}> + Loading...}> + {(result) => } + +
+
+
+ ) +} diff --git a/packages/console/support/sst-env.d.ts b/packages/console/support/sst-env.d.ts new file mode 100644 index 0000000000..301538ccb2 --- /dev/null +++ b/packages/console/support/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/support/tsconfig.json b/packages/console/support/tsconfig.json new file mode 100644 index 0000000000..0f96f182ce --- /dev/null +++ b/packages/console/support/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "allowJs": true, + "strict": true, + "noEmit": true, + "types": ["vite/client", "bun"], + "isolatedModules": true, + "paths": { + "~/*": ["./src/*"] + } + } +} diff --git a/packages/console/support/vite.config.ts b/packages/console/support/vite.config.ts new file mode 100644 index 0000000000..3b013e9901 --- /dev/null +++ b/packages/console/support/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, PluginOption } from "vite" +import { solidStart } from "@solidjs/start/config" +import { nitro } from "nitro/vite" + +export default defineConfig({ + plugins: [ + solidStart() as PluginOption, + nitro({ + compatibilityDate: "2024-09-19", + preset: "cloudflare_module", + cloudflare: { + nodeCompat: true, + }, + }), + ], + server: { + allowedHosts: true, + }, + build: { + rollupOptions: { + external: ["cloudflare:workers"], + }, + minify: false, + }, +})