mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
zen: support dashboard
This commit is contained in:
@@ -192,6 +192,26 @@
|
|||||||
"cloudflare": "5.2.0",
|
"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": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.15.10",
|
"version": "1.15.10",
|
||||||
@@ -1631,6 +1651,8 @@
|
|||||||
|
|
||||||
"@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"],
|
"@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/core": ["@opencode-ai/core@workspace:packages/core"],
|
||||||
|
|
||||||
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
|
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Router
|
||||||
|
explicitLinks={true}
|
||||||
|
root={(props) => (
|
||||||
|
<MetaProvider>
|
||||||
|
<Title>opencode support</Title>
|
||||||
|
<Suspense>{props.children}</Suspense>
|
||||||
|
</MetaProvider>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileRoutes />
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import type { LookupResult, WorkspaceSection } from "~/lib/lookup"
|
||||||
|
|
||||||
|
export function Result(props: { data: LookupResult }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Show when={props.data.auth}>
|
||||||
|
{(auth) => (
|
||||||
|
<section data-component="section">
|
||||||
|
<h2>Auth</h2>
|
||||||
|
<DataTable rows={auth()} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.data.accountWorkspaces}>
|
||||||
|
{(workspaces) => (
|
||||||
|
<section data-component="section">
|
||||||
|
<h2>Workspaces</h2>
|
||||||
|
<DataTable rows={workspaces()} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={props.data.workspaces}>{(ws) => <WorkspaceView section={ws} />}</For>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceView(props: { section: WorkspaceSection }) {
|
||||||
|
return (
|
||||||
|
<section data-component="section" id={`workspace-${props.section.workspaceID}`}>
|
||||||
|
<h2>{props.section.title}</h2>
|
||||||
|
|
||||||
|
<h3>Users</h3>
|
||||||
|
<DataTable rows={props.section.users} />
|
||||||
|
|
||||||
|
<h3>Billing</h3>
|
||||||
|
<DataTable rows={props.section.billing ? [props.section.billing] : []} />
|
||||||
|
|
||||||
|
<h3>GO</h3>
|
||||||
|
<DataTable rows={props.section.go} />
|
||||||
|
|
||||||
|
<h3>Payments</h3>
|
||||||
|
<DataTable rows={props.section.payments} />
|
||||||
|
|
||||||
|
<h3>28-Day Usage</h3>
|
||||||
|
<DataTable rows={props.section.usage} />
|
||||||
|
|
||||||
|
<h3>Disabled Models</h3>
|
||||||
|
<DataTable rows={props.section.disabledModels} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable(props: { rows: Record<string, unknown>[] }) {
|
||||||
|
const columns = () => {
|
||||||
|
const cols = new Set<string>()
|
||||||
|
for (const row of props.rows) {
|
||||||
|
for (const key of Object.keys(row)) cols.add(key)
|
||||||
|
}
|
||||||
|
return [...cols]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.rows.length > 0} fallback={<div data-empty>(no data)</div>}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<For each={columns()}>{(col) => <th>{col}</th>}</For>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<For each={props.rows}>
|
||||||
|
{(row) => (
|
||||||
|
<tr>
|
||||||
|
<For each={columns()}>{(col) => <td>{renderCell(row[col])}</td>}</For>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return ""
|
||||||
|
if (typeof value === "string" && value.startsWith("https://")) {
|
||||||
|
return (
|
||||||
|
<a href={value} target="_blank" rel="noopener noreferrer">
|
||||||
|
{value}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isLinkCell(value)) {
|
||||||
|
const external = value.__link.startsWith("http")
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={value.__link}
|
||||||
|
target={external ? "_blank" : undefined}
|
||||||
|
rel={external ? "noopener noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{value.label}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// @refresh reload
|
||||||
|
import { mount, StartClient } from "@solidjs/start/client"
|
||||||
|
|
||||||
|
mount(() => <StartClient />, document.getElementById("app")!)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @refresh reload
|
||||||
|
import { createHandler, StartServer } from "@solidjs/start/server"
|
||||||
|
|
||||||
|
export default createHandler(
|
||||||
|
() => (
|
||||||
|
<StartServer
|
||||||
|
document={({ assets, children, scripts }) => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex,nofollow" />
|
||||||
|
{assets}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">{children}</div>
|
||||||
|
{scripts}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
mode: "async",
|
||||||
|
},
|
||||||
|
)
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@solidjs/start/env" />
|
||||||
@@ -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<string, unknown>[]
|
||||||
|
accountWorkspaces?: Record<string, unknown>[]
|
||||||
|
workspaces: WorkspaceSection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceSection = {
|
||||||
|
workspaceID: string
|
||||||
|
title: string
|
||||||
|
users: Record<string, unknown>[]
|
||||||
|
billing: Record<string, unknown> | null
|
||||||
|
go: Record<string, unknown>[]
|
||||||
|
payments: Record<string, unknown>[]
|
||||||
|
usage: Record<string, unknown>[]
|
||||||
|
disabledModels: Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookup(identifier: string): Promise<LookupResult> {
|
||||||
|
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<WorkspaceSection> {
|
||||||
|
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<string>`DATE(${UsageTable.timeCreated})`.as("date"),
|
||||||
|
freeRequests: sql<number>`SUM(CASE WHEN ${UsageTable.cost} = 0 THEN 1 ELSE 0 END)`.as("free_requests"),
|
||||||
|
goRequests: sql<number>`SUM(CASE WHEN ${planExpr} = 'lite' THEN 1 ELSE 0 END)`.as("go_requests"),
|
||||||
|
goCost: sql<number>`SUM(CASE WHEN ${planExpr} = 'lite' THEN ${UsageTable.cost} ELSE 0 END)`.as("go_cost"),
|
||||||
|
apiRequests: sql<number>`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN 1 ELSE 0 END)`.as("api_requests"),
|
||||||
|
apiCost: sql<number>`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<string, unknown>[] = 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Title } from "@solidjs/meta"
|
||||||
|
|
||||||
|
export default function SupportPage() {
|
||||||
|
return (
|
||||||
|
<main data-page="support">
|
||||||
|
<Title>opencode support — lookup user</Title>
|
||||||
|
<h1>Lookup user</h1>
|
||||||
|
|
||||||
|
<form data-component="lookup" action="/lookup" method="get" target="_blank">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="identifier"
|
||||||
|
placeholder="email, wrk_..., key_..., or sk-..."
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit">Lookup</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main data-page="support">
|
||||||
|
<Title>opencode support — {identifier() || "lookup"}</Title>
|
||||||
|
<h1>Lookup: {identifier() || "(no identifier)"}</h1>
|
||||||
|
|
||||||
|
<Show when={identifier()} fallback={<div data-empty>Provide an `identifier` query parameter.</div>}>
|
||||||
|
<ErrorBoundary fallback={(err) => <div data-component="error">{(err as Error).message}</div>}>
|
||||||
|
<Show when={data()} fallback={<div data-empty>Loading...</div>}>
|
||||||
|
{(result) => <Result data={result()} />}
|
||||||
|
</Show>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Show>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
+10
@@ -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 */
|
||||||
|
|
||||||
|
/// <reference path="../../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user