mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud): PAIRED-BILLING a wake the plan refused says why, never the code
The waking card showed the control plane's code on a billing refusal (paired: "entitlement_denied"; owned: "Orca Cloud refused the request (entitlement_denied).") and offered Restart, which fails the same way. Failed wake/restart/unarchive events now carry the plan's refusal (billingDenial), from main's recipe resume, restart and unarchive and from the paired wake. The card uses the create sheet's billing words, offers the one fix to an owner or admin (See plans, Raise spend cap, Update payment method) and tells a member who to ask; running limits keep the shared workspace copy.
This commit is contained in:
@@ -72,3 +72,8 @@ export class HostedEntitlementDeniedError extends EnvironmentProviderError {
|
||||
export function isHostedEntitlementDenied(error: unknown): error is HostedEntitlementDeniedError {
|
||||
return error instanceof HostedEntitlementDeniedError
|
||||
}
|
||||
|
||||
/** For a failed wake/restart/unarchive event: the plan's refusal, so the card can say why. */
|
||||
export function wakeBillingDenial(error: unknown): { billingDenial?: { reason: string | null } } {
|
||||
return isHostedEntitlementDenied(error) ? { billingDenial: { reason: error.reason ?? null } } : {}
|
||||
}
|
||||
|
||||
@@ -245,14 +245,11 @@ export function installHostedSandbox(
|
||||
usageRefresh.noteStarted()
|
||||
},
|
||||
onStopped: (environmentId) => registry.stopEnvironment(environmentId),
|
||||
onWake: ({ workspaceId, phase, startedAt, message }) =>
|
||||
onWake: (event) =>
|
||||
workspaces.emitWakeProgress({
|
||||
workspaceId,
|
||||
phase,
|
||||
startedAt,
|
||||
message,
|
||||
...event,
|
||||
// Later phases inherit the source recorded at `waking`.
|
||||
source: phase === 'waking' ? workspaces.takeWakeSource(workspaceId) : 'open'
|
||||
source: event.phase === 'waking' ? workspaces.takeWakeSource(event.workspaceId) : 'open'
|
||||
})
|
||||
})
|
||||
installed = { registry }
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isEnvironmentProviderError } from '../environment-providers/environment
|
||||
import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter'
|
||||
import {
|
||||
isHostedEntitlementDenied,
|
||||
wakeBillingDenial,
|
||||
type HostedEnvironmentProviderDriver,
|
||||
type HostedEnvironmentReady
|
||||
} from './hosted-environment-provider'
|
||||
@@ -153,6 +154,7 @@ export function createHostedSandboxStepProvider(
|
||||
phase: 'waking' | 'connecting' | 'ready' | 'failed'
|
||||
startedAt: number
|
||||
message?: string
|
||||
billingDenial?: { reason: string | null }
|
||||
}) => void
|
||||
} = {}
|
||||
): RecipeStepProvider {
|
||||
@@ -220,7 +222,8 @@ export function createHostedSandboxStepProvider(
|
||||
workspaceId,
|
||||
phase: 'failed',
|
||||
startedAt,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...wakeBillingDenial(error)
|
||||
})
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// A failed wake/restart/unarchive carries the plan's refusal, so the window's card can say why.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HostedEntitlementDeniedError, wakeBillingDenial } from './hosted-environment-provider'
|
||||
|
||||
describe('wakeBillingDenial', () => {
|
||||
it('carries the billing reason of a plan refusal', () => {
|
||||
const error = new HostedEntitlementDeniedError('refused', 'subscription_inactive')
|
||||
expect(wakeBillingDenial(error)).toEqual({ billingDenial: { reason: 'subscription_inactive' } })
|
||||
expect(wakeBillingDenial(new HostedEntitlementDeniedError('refused', null))).toEqual({
|
||||
billingDenial: { reason: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('adds nothing for any other failure', () => {
|
||||
expect(wakeBillingDenial(new Error('boom'))).toEqual({})
|
||||
expect(wakeBillingDenial('x')).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ import { invalidateRuntimeEnvironmentTransport } from '../ipc/runtime-environmen
|
||||
import { autoResumeAgentsAfterRollover, listRestartOfferIds } from './hosted-agent-auto-resume'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
import { toReady } from './hosted-sandbox-driver'
|
||||
import { wakeBillingDenial } from './hosted-environment-provider'
|
||||
import { noteWakeTimeline } from './cloud-wake-timeline'
|
||||
import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment-call'
|
||||
import { getCloudSyncService } from '../cloud-sync/cloud-sync-ipc'
|
||||
@@ -255,7 +256,8 @@ export function installHostedWorkspaceService(deps: {
|
||||
phase: 'failed',
|
||||
source: 'restart',
|
||||
startedAt,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...wakeBillingDenial(error)
|
||||
})
|
||||
deps.syncSessions()
|
||||
return failure(error)
|
||||
@@ -313,7 +315,8 @@ export function installHostedWorkspaceService(deps: {
|
||||
phase: 'failed',
|
||||
source: 'unarchive',
|
||||
startedAt,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
...wakeBillingDenial(error)
|
||||
})
|
||||
return failure(error)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ import { useTranslate } from '@/i18n/use-translate'
|
||||
import { cn } from '@/lib/class-names'
|
||||
import { wakeElapsedSeconds } from '@/lib/orca-cloud/orca-cloud-workspace-state'
|
||||
import { TERMINAL_CONTENT_INSET_PX, useTerminalContentInset } from './use-terminal-content-inset'
|
||||
import { useBillingSummary } from '@/components/billing/billing-summary-store'
|
||||
import {
|
||||
cloudWakeBillingCopy,
|
||||
cloudWakeBillingFixLabel,
|
||||
runCloudWakeBillingFix
|
||||
} from './cloud-wake-billing'
|
||||
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
|
||||
/** After this long the pill says it's slower than usual; after STUCK_S it offers recovery inline. */
|
||||
@@ -101,6 +107,10 @@ export function CloudWakingOverlay({
|
||||
const t = useTranslate()
|
||||
const failed = wake.phase === 'failed'
|
||||
const unknownServer = failed && isUnknownServerWakeFailure(wake.message)
|
||||
const { canManageBilling } = useBillingSummary()
|
||||
// The plan refused it: say why, offer the fix to someone who can make it, no Restart (it fails too).
|
||||
const billing = cloudWakeBillingCopy(wake, canManageBilling, t)
|
||||
const billingFix = billing?.fix ?? null
|
||||
const now = useNow(1_000, !failed && !leaving)
|
||||
const seconds = wakeElapsedSeconds(wake.startedAt, Math.max(now, wake.at))
|
||||
const slow = !failed && seconds >= WAKE_SLOW_S
|
||||
@@ -215,7 +225,9 @@ export function CloudWakingOverlay({
|
||||
name
|
||||
})}
|
||||
</div>
|
||||
{wake.message && !unknownServer ? (
|
||||
{billing ? (
|
||||
<p className="break-words text-xs text-foreground">{billing.body}</p>
|
||||
) : wake.message && !unknownServer ? (
|
||||
<p className="break-words text-xs text-foreground">{wake.message}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -238,14 +250,24 @@ export function CloudWakingOverlay({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{terminalStalled ? null : (
|
||||
{terminalStalled || billing ? null : (
|
||||
<Button size="sm" variant="ghost" onClick={onRestart}>
|
||||
{t('orcaCloudWorkspace.restartSandbox', 'Restart sandbox')}
|
||||
</Button>
|
||||
)}
|
||||
<Button ref={retryRef} size="sm" onClick={onRetry}>
|
||||
<Button
|
||||
ref={retryRef}
|
||||
size="sm"
|
||||
variant={billingFix ? 'ghost' : 'default'}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t('orcaCloudWorkspace.overlay.tryAgain', 'Try again')}
|
||||
</Button>
|
||||
{billingFix ? (
|
||||
<Button size="sm" onClick={() => runCloudWakeBillingFix(billingFix)}>
|
||||
{cloudWakeBillingFixLabel(billingFix, t)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -4,6 +4,8 @@ import React from 'react'
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
import { CloudWakingOverlay } from './CloudWakingOverlay'
|
||||
import { recordBillingStatus } from '../billing/billing-summary-store'
|
||||
import { sampleTeam } from '../billing/plan-billing-fixtures'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
@@ -128,3 +130,42 @@ export const wakeFailedServerError = defineFixture({
|
||||
</Pane>
|
||||
)
|
||||
})
|
||||
|
||||
function stubBillingRole(canManageBilling: boolean): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fixture entitlement overrides only the role the card reads.
|
||||
const result = { status: 'ok', entitlement: { ...sampleTeam, canManageBilling } } as never
|
||||
window.api.orcaProfiles.billingStatus = () => Promise.resolve(result)
|
||||
recordBillingStatus(result)
|
||||
}
|
||||
|
||||
// The plan refused the wake: the card says why in plain words (never "entitlement_denied"), offers
|
||||
// plans only where buying fixes it and only to an owner or admin, and drops Restart (it fails too).
|
||||
function wakeRefused(reason: string | null, canManageBilling = true) {
|
||||
return defineFixture({
|
||||
width: 640,
|
||||
setup: () => stubBillingRole(canManageBilling),
|
||||
render: () => (
|
||||
<Pane>
|
||||
<CloudWakingOverlay
|
||||
wake={startedAgo(4_000, {
|
||||
phase: 'failed',
|
||||
message: 'entitlement_denied',
|
||||
billingDenial: { reason }
|
||||
})}
|
||||
workspaceName="fix-auth-redirect"
|
||||
autoFocus={false}
|
||||
onRetry={noop}
|
||||
onRestart={noop}
|
||||
onDismiss={noop}
|
||||
/>
|
||||
</Pane>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const wakeRefusedPlanEnded = wakeRefused('subscription_inactive')
|
||||
export const wakeRefusedHoursUsed = wakeRefused('included_hours_exhausted')
|
||||
export const wakeRefusedSpendCap = wakeRefused('overage_cap_reached')
|
||||
export const wakeRefusedPayment = wakeRefused('payment_past_due')
|
||||
export const wakeRefusedTeamLimit = wakeRefused('concurrency_limit')
|
||||
export const wakeRefusedPlanEndedMember = wakeRefused('subscription_inactive', false)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// A wake the plan refused says why in the create sheet's words, offers the fix only to an owner or
|
||||
// admin (a member reads who to ask), and never shows the control plane's code.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cloudWakeBillingCopy } from './cloud-wake-billing'
|
||||
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
|
||||
const failed = (reason: string | null, over: Partial<CloudWakeProgressEvent> = {}) =>
|
||||
({
|
||||
workspaceId: 'ws',
|
||||
phase: 'failed',
|
||||
source: 'open',
|
||||
startedAt: 1,
|
||||
at: 2,
|
||||
message: 'entitlement_denied',
|
||||
billingDenial: { reason },
|
||||
...over
|
||||
}) satisfies CloudWakeProgressEvent
|
||||
|
||||
describe('cloudWakeBillingCopy', () => {
|
||||
it('is null for a failure the plan did not cause, and while still waking', () => {
|
||||
expect(
|
||||
cloudWakeBillingCopy({ ...failed(null), billingDenial: undefined }, true, translate)
|
||||
).toBeNull()
|
||||
expect(
|
||||
cloudWakeBillingCopy(failed('subscription_inactive', { phase: 'waking' }), true, translate)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('an owner or admin gets the fix that applies', () => {
|
||||
const fix = (reason: string) => cloudWakeBillingCopy(failed(reason), true, translate)?.fix
|
||||
expect(fix('subscription_inactive')).toBe('seePlans')
|
||||
expect(fix('included_hours_exhausted')).toBe('seePlans')
|
||||
expect(fix('overage_cap_reached')).toBe('raiseSpendCap')
|
||||
expect(fix('payment_past_due')).toBe('updatePayment')
|
||||
})
|
||||
|
||||
it('a member gets no fix button, and the copy says who to ask', () => {
|
||||
const copy = cloudWakeBillingCopy(failed('subscription_inactive'), false, translate)
|
||||
expect(copy?.fix).toBeNull()
|
||||
expect(copy?.body).toMatch(/Ask an owner or admin/)
|
||||
// An owner is never told to ask an owner.
|
||||
expect(
|
||||
cloudWakeBillingCopy(failed('subscription_inactive'), true, translate)?.body
|
||||
).not.toMatch(/Ask an owner/)
|
||||
})
|
||||
|
||||
it('running limits and unknown reasons use the shared copy, with no billing fix', () => {
|
||||
expect(cloudWakeBillingCopy(failed('concurrency_limit'), true, translate)?.fix).toBeNull()
|
||||
expect(cloudWakeBillingCopy(failed('something_new'), true, translate)?.fix).toBeNull()
|
||||
})
|
||||
|
||||
it('never shows the raw code', () => {
|
||||
for (const reason of [null, 'subscription_inactive', 'concurrency_limit', 'something_new']) {
|
||||
for (const owner of [true, false]) {
|
||||
const copy = cloudWakeBillingCopy(failed(reason), owner, translate)
|
||||
expect(copy?.body).toBeTruthy()
|
||||
expect(copy?.body).not.toMatch(/entitlement|_/)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// A wake, restart or unarchive the plan refused: the failure card says why in the same words as the
|
||||
// create sheet's billing blocker, and offers the one fix that applies to someone who can make it
|
||||
// (owner or admin). A member reads who to ask. Never the control plane's code.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { useAppStore } from '@/store'
|
||||
import { describeError } from '@/components/cloud-workspaces/cloud-workspace-errors'
|
||||
import {
|
||||
blockerCopy,
|
||||
type BillingBlockKind
|
||||
} from '@/components/worktree-creation/VmBuildBillingBlocker'
|
||||
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
|
||||
import { ORCA_BILLING_OVERAGE_SECTION_ID } from '@/components/new-workspace/orca-cloud-run-target-access'
|
||||
import { classifyEntitlementDenial } from '../../../../shared/orca-billing'
|
||||
import type { CloudWakeProgressEvent } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
|
||||
export type CloudWakeBillingFix = 'seePlans' | 'raiseSpendCap' | 'updatePayment'
|
||||
export type CloudWakeBillingCopy = { body: string; fix: CloudWakeBillingFix | null }
|
||||
|
||||
const BLOCK_KIND: Partial<Record<ReturnType<typeof classifyEntitlementDenial>, BillingBlockKind>> =
|
||||
{ plan: 'plan', hours: 'hoursUsed', spendCap: 'spendCap', payment: 'payment' }
|
||||
|
||||
const FIX: Record<BillingBlockKind, CloudWakeBillingFix> = {
|
||||
plan: 'seePlans',
|
||||
hoursUsed: 'seePlans',
|
||||
spendCap: 'raiseSpendCap',
|
||||
payment: 'updatePayment'
|
||||
}
|
||||
|
||||
export function cloudWakeBillingCopy(
|
||||
wake: CloudWakeProgressEvent,
|
||||
canManageBilling: boolean,
|
||||
t: Translate
|
||||
): CloudWakeBillingCopy | null {
|
||||
if (wake.phase !== 'failed' || !wake.billingDenial) {
|
||||
return null
|
||||
}
|
||||
const reason = wake.billingDenial.reason ?? undefined
|
||||
const kind = BLOCK_KIND[classifyEntitlementDenial(reason)]
|
||||
if (!kind) {
|
||||
// Running limits and anything unknown: the shared workspace copy (it says what to do).
|
||||
return { body: describeError('entitlement_denied', reason, t), fix: null }
|
||||
}
|
||||
const copy = blockerCopy(kind, t)
|
||||
// The sheet's payment line is about new sandboxes; this one already exists.
|
||||
const body =
|
||||
kind === 'payment'
|
||||
? t('orcaCloudWorkspace.overlay.billingPayment', 'Your team’s last payment failed.')
|
||||
: copy.body
|
||||
return canManageBilling ? { body, fix: FIX[kind] } : { body: `${body} ${copy.ask}`, fix: null }
|
||||
}
|
||||
|
||||
export function cloudWakeBillingFixLabel(fix: CloudWakeBillingFix, t: Translate): string {
|
||||
switch (fix) {
|
||||
case 'seePlans':
|
||||
return t('orcaCloudWorkspace.error.seePlans', 'See plans')
|
||||
case 'raiseSpendCap':
|
||||
return t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.raiseSpendCap',
|
||||
'Raise spend cap'
|
||||
)
|
||||
case 'updatePayment':
|
||||
return t(
|
||||
'auto.components.worktree.creation.VmBuildFailureCard.updatePayment',
|
||||
'Update payment method'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function runCloudWakeBillingFix(fix: CloudWakeBillingFix): void {
|
||||
if (fix === 'seePlans') {
|
||||
openPlansPage('sandbox')
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({
|
||||
pane: 'cloud-billing',
|
||||
repoId: null,
|
||||
// The spend cap lives on the overage card; the payment notice tops the section already.
|
||||
...(fix === 'raiseSpendCap' ? { sectionId: ORCA_BILLING_OVERAGE_SECTION_ID } : {})
|
||||
})
|
||||
store.openSettingsPage()
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export type BillingBlockerPhase =
|
||||
/** A fix in the browser (portal): re-check on return, or with Check again. */
|
||||
| { kind: 'browser'; checking: boolean; stillBlocked: boolean }
|
||||
|
||||
function blockerCopy(
|
||||
/** The blocker's words per refusal; also the waking card's, so a refused wake says the same. */
|
||||
export function blockerCopy(
|
||||
kind: BillingBlockKind,
|
||||
t: Translate
|
||||
): { title: string; body: string; ask: string } {
|
||||
|
||||
@@ -27699,7 +27699,8 @@
|
||||
"pendingKeys_one": "{{count}} key waiting · it goes to the terminal when it’s up",
|
||||
"pendingKeys_other": "{{count}} keys waiting · they go to the terminal when it’s up",
|
||||
"clearPendingKeys": "Discard waiting keys",
|
||||
"unarchivingNow": "Unarchiving…"
|
||||
"unarchivingNow": "Unarchiving…",
|
||||
"billingPayment": "Your team’s last payment failed."
|
||||
},
|
||||
"announce": {
|
||||
"restarting": "Restarting {{name}}.",
|
||||
|
||||
@@ -85,6 +85,24 @@ describe('waking a paired workspace', () => {
|
||||
expect(getCloudWorkspaceSnapshot().wake[ROW]?.phase).toBe('failed')
|
||||
})
|
||||
|
||||
it('a plan refusal marks the failed wake as billing, so the card says why (never the code)', async () => {
|
||||
wake.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'entitlement_denied',
|
||||
reason: 'included_hours_exhausted'
|
||||
})
|
||||
await wakePairedCloudWorkspace(ROW)
|
||||
expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toEqual({
|
||||
reason: 'included_hours_exhausted'
|
||||
})
|
||||
})
|
||||
|
||||
it('any other refusal is not marked as billing', async () => {
|
||||
wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' })
|
||||
await wakePairedCloudWorkspace(ROW)
|
||||
expect(getCloudWorkspaceSnapshot().wake[ROW]?.billingDenial).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a predictive (quiet) wake that is refused leaves no failed card behind', async () => {
|
||||
wake.mockResolvedValueOnce({ ok: false, error: 'Sandbox hours used up', reason: 'quota' })
|
||||
expect((await wakePairedCloudWorkspace(ROW, { quiet: true })).ok).toBe(false)
|
||||
|
||||
@@ -48,7 +48,10 @@ export async function wakePairedCloudWorkspace(
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
const source = options.source ?? 'open'
|
||||
const report = (phase: 'waking' | 'ready' | 'failed', message?: string): void => {
|
||||
const report = (
|
||||
phase: 'waking' | 'ready' | 'failed',
|
||||
failure?: { message: string; reason?: string }
|
||||
): void => {
|
||||
if (!options.quiet) {
|
||||
noteCloudWakeProgress({
|
||||
workspaceId,
|
||||
@@ -56,7 +59,11 @@ export async function wakePairedCloudWorkspace(
|
||||
source,
|
||||
startedAt,
|
||||
at: Date.now(),
|
||||
...(message ? { message } : {})
|
||||
...(failure ? { message: failure.message } : {}),
|
||||
// The plan refused it: the card says why (and offers plans), never the raw code.
|
||||
...(failure?.message === 'entitlement_denied'
|
||||
? { billingDenial: { reason: failure.reason ?? null } }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -69,7 +76,7 @@ export async function wakePairedCloudWorkspace(
|
||||
if (result.ok) {
|
||||
report('ready')
|
||||
} else {
|
||||
report('failed', result.error)
|
||||
report('failed', { message: result.error, ...(result.reason ? { reason: result.reason } : {}) })
|
||||
}
|
||||
await refreshCloudWorkspaceStatus(workspaceId)
|
||||
return result.ok
|
||||
|
||||
@@ -44,6 +44,8 @@ export type CloudWakeProgressEvent = {
|
||||
startedAt: number
|
||||
at: number
|
||||
message?: string
|
||||
/** The plan refused it (billing's deny reason, null when unsaid): the card says why, not the code. */
|
||||
billingDenial?: { reason: string | null }
|
||||
}
|
||||
|
||||
export type CloudOomEvent = {
|
||||
|
||||
Reference in New Issue
Block a user