diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..130bba1a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prettier.trailingComma": "all" +} diff --git a/infra/billing.ts b/infra/billing.ts index 6f1c030f..422b506c 100644 --- a/infra/billing.ts +++ b/infra/billing.ts @@ -1,6 +1,6 @@ import { bus } from "./bus"; import { database } from "./planetscale"; -import { assumable, secret } from "./secret"; +import { allSecrets, assumable, secret } from "./secret"; const queue = new sst.aws.Queue("BillingQueue", { fifo: true, @@ -9,7 +9,7 @@ const queue = new sst.aws.Queue("BillingQueue", { queue.subscribe( { - link: [database, secret.StripeSecretKey], + link: [database, ...allSecrets], handler: "packages/functions/src/billing/fetch-usage.handler", permissions: [assumable], timeout: "3 minutes", @@ -27,6 +27,6 @@ new sst.aws.Cron("BillingCron", { handler: "packages/functions/src/billing/cron.handler", timeout: "900 seconds", permissions: [assumable], - link: [bus, database, queue], + link: [bus, database, queue, ...allSecrets], }, }); diff --git a/infra/secret.ts b/infra/secret.ts index e25501e0..5d1bafb2 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -10,8 +10,8 @@ export const secret = { StripeResourcesPriceID: new sst.Secret( "StripeResourcesPriceID", $app.stage === "production" - ? "price_xyz123" - : "price_1QgxZcEAHP8a0ogpIT1qxKlV" + ? "price_1QhwLAEAHP8a0ogpjRV91Yl8" + : "price_1Qi4QzEAHP8a0ogpDvPDu8Bm" ), SlackClientID: new sst.Secret("SlackClientID"), SlackClientSecret: new sst.Secret("SlackClientSecret"), diff --git a/infra/web.ts b/infra/web.ts index cabbf5aa..389df4fa 100644 --- a/infra/web.ts +++ b/infra/web.ts @@ -17,7 +17,7 @@ new sst.aws.StaticSite("Workspace", { }), }, environment: { - VITE_API_URL: backend.url, + VITE_API_URL: apiRouter.url, VITE_AUTH_URL: authRouter.url, VITE_STAGE: $app.stage, VITE_CONNECT_URL: connectTemplateUrl, diff --git a/package.json b/package.json index 9d021108..846e3abb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "env": "env", "prepare": "git config --local core.hooksPath .githooks", "dev": "sst dev", - "sso": "aws sso login --sso-session=sst --no-browser --use-device-code", + "sso": "aws sso login --sso-session=sst --no-browser", "build": "sst build", "deploy": "sst deploy", "remove": "sst remove", diff --git a/packages/backend/src/api/billing.ts b/packages/backend/src/api/billing.ts index d6b9b499..e6f6b173 100644 --- a/packages/backend/src/api/billing.ts +++ b/packages/backend/src/api/billing.ts @@ -19,7 +19,7 @@ export const BillingRoute = new Hono() mode: "subscription", line_items: [ { - price: Resource.StripePriceID.value, + price: Resource.StripeResourcesPriceID.value, }, ], customer: item.customerID, diff --git a/packages/backend/src/api/webhook.ts b/packages/backend/src/api/webhook.ts index 7f085b01..066137c8 100644 --- a/packages/backend/src/api/webhook.ts +++ b/packages/backend/src/api/webhook.ts @@ -11,19 +11,19 @@ WebhookRoute.post("/stripe", async (c) => { const body = stripe.webhooks.constructEvent( await c.req.text(), c.req.header("stripe-signature")!, - Resource.StripeWebhookSigningSecret.value, + Resource.StripeWebhookSigningSecret.value ); console.log(body.type, body); if (body.type === "customer.subscription.created") { const { id: subscriptionID, customer, items } = body.data.object; const item = await Billing.Stripe.fromCustomerID(customer as string); - if (!item) { - throw new Error("Workspace not found for customer"); - } - if (item.subscriptionID) { + if (!item) throw new Error("Workspace not found for customer"); + if (item.subscriptionID) throw new Error("Workspace already has a subscription"); - } + if (!items.data[0]) throw new Error("Subscription items is empty"); + const subscriptionItemID = items.data[0].id; + const priceID = items.data[0].price.id; await withActor( { @@ -33,14 +33,13 @@ WebhookRoute.post("/stripe", async (c) => { }, }, async () => { - if (!items.data[0]) throw new Error("Subscription items is empty"); - await Billing.Stripe.setSubscription({ subscriptionID, - subscriptionItemID: items.data[0].id, + subscriptionItemID, + priceID, }); await Billing.updateGatingStatus(); - }, + } ); } else if (body.type === "customer.subscription.updated") { const { id: subscriptionID, customer, status } = body.data.object; @@ -74,7 +73,7 @@ WebhookRoute.post("/stripe", async (c) => { }); await Billing.updateGatingStatus(); } - }, + } ); } else if (body.type === "customer.subscription.deleted") { const { id: subscriptionID } = body.data.object; @@ -93,7 +92,7 @@ WebhookRoute.post("/stripe", async (c) => { invoice: id, customer, created: new Date(created * 1000), - }, + } ); } diff --git a/packages/backend/src/replicache/dummy/data.ts b/packages/backend/src/replicache/dummy/data.ts index 0a701a26..1d122c1e 100644 --- a/packages/backend/src/replicache/dummy/data.ts +++ b/packages/backend/src/replicache/dummy/data.ts @@ -3285,8 +3285,11 @@ function stripe({ standing }: StripeProps): DummyData { subscriptionID: "sub_123", standing: standing || "good", subscriptionItemID: "sub_item_123", - timeDeleted: null, - timeTrialEnded: null, + price: "resources", + time: { + created: DateTime.now().startOf("day").toISO()!, + updated: DateTime.now().startOf("day").toISO()!, + }, ...timestamps, }; } diff --git a/packages/core/src/app/stage.ts b/packages/core/src/app/stage.ts index 9a5953bd..1162b910 100644 --- a/packages/core/src/app/stage.ts +++ b/packages/core/src/app/stage.ts @@ -10,21 +10,11 @@ import { import { createId } from "@paralleldrive/cuid2"; import { useWorkspace } from "../actor"; import { awsAccount } from "../aws/aws.sql"; -import { and, asc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import { AWS } from "../aws"; -import { - GetObjectCommand, - ListObjectsV2Command, - S3Client, -} from "@aws-sdk/client-s3"; -import { Enrichers, Resource } from "./resource"; -import { db } from "../drizzle"; import { createEvent } from "../event"; import { Replicache } from "../replicache"; import { issueSubscriber } from "../issue/issue.sql"; -import { bus } from "sst/aws/bus"; -import { Resource as SSTResource } from "sst"; -import { State } from "../state"; export * as Stage from "./stage"; export const Events = { @@ -154,28 +144,6 @@ export const put = zod( }), ); -export const list = zod( - z.object({ - cursor: z.string().min(1).optional(), - }), - ({ cursor }) => - useTransaction(async (tx) => { - const SIZE = 100000; - const items = await tx - .select() - .from(stage) - .where(cursor ? gt(stage.id, cursor) : undefined) - .limit(SIZE) - .orderBy(asc(stage.id)) - .execute() - .then((rows) => rows); - return { - items, - cursor: items.length < SIZE ? undefined : items.at(-1)?.id, - }; - }), -); - export type StageCredentials = Exclude< Awaited>, undefined diff --git a/packages/core/src/billing/billing.sql.ts b/packages/core/src/billing/billing.sql.ts index ca0f42db..c902839d 100644 --- a/packages/core/src/billing/billing.sql.ts +++ b/packages/core/src/billing/billing.sql.ts @@ -10,6 +10,8 @@ import { } from "drizzle-orm/mysql-core"; import { timestamps, workspaceID, cuid } from "../util/sql"; +export const Standing = ["good", "overdue"] as const; + export const usage = mysqlTable( "usage", { @@ -37,7 +39,7 @@ export const stripeTable = mysqlTable( length: 255, }), priceID: varchar("price_id", { length: 255 }), - standing: mysqlEnum("standing", ["good", "overdue"]), + standing: mysqlEnum("standing", Standing), timeTrialEnded: timestamp("time_trial_ended", { mode: "string" }), }, (table) => ({ diff --git a/packages/core/src/billing/index.ts b/packages/core/src/billing/index.ts index fc81493d..ea9d56db 100644 --- a/packages/core/src/billing/index.ts +++ b/packages/core/src/billing/index.ts @@ -2,7 +2,6 @@ import { createSelectSchema } from "drizzle-zod"; import { usage } from "./billing.sql"; import { z } from "zod"; import { zod } from "../util/zod"; -import { createId } from "@paralleldrive/cuid2"; import { eq, and, between, sql } from "drizzle-orm"; import { useTransaction } from "../util/transaction"; import { useWorkspace } from "../actor"; @@ -10,6 +9,8 @@ import { workspace } from "../workspace/workspace.sql"; import { Stripe } from "./stripe"; import { DateTime } from "luxon"; import { Warning } from "../warning"; +import { stateCountTable } from "../state/state.sql"; +import { Resource } from "sst"; export * as Billing from "./index"; export { Stripe } from "./stripe"; @@ -20,31 +21,9 @@ export const Usage = createSelectSchema(usage, { }); export type Usage = z.infer; -const FREE_INVOCATIONS = 1000000; +const FREE_RESOURCES = 200; -export const createUsage = zod( - Usage.pick({ stageID: true, day: true, invocations: true }), - (input) => - useTransaction((tx) => - tx - .insert(usage) - .values({ - id: createId(), - workspaceID: useWorkspace(), - stageID: input.stageID, - day: input.day, - invocations: input.invocations, - }) - .onDuplicateKeyUpdate({ - set: { - invocations: input.invocations, - }, - }) - .execute(), - ), -); - -export const countByStartAndEndDay = zod( +export const countInvocationsByStartAndEndDay = zod( z.object({ startDay: Usage.shape.day, endDay: Usage.shape.day, @@ -57,13 +36,36 @@ export const countByStartAndEndDay = zod( .where( and( eq(usage.workspaceID, useWorkspace()), - between(usage.day, input.startDay, input.endDay), - ), + between(usage.day, input.startDay, input.endDay) + ) ) - .execute(), + .execute() ); return rows.reduce((acc, usage) => acc + usage.invocations, 0); - }, + } +); + +export const countResourcesByMonth = zod( + z.object({ + month: z.string().min(1), + }), + async (input) => { + return await useTransaction((tx) => + tx + .select({ + total: sql`SUM(${stateCountTable.count})`, + }) + .from(stateCountTable) + .where( + and( + eq(stateCountTable.workspaceID, useWorkspace()), + eq(stateCountTable.month, input.month) + ) + ) + .execute() + .then((x) => x[0]?.total ?? 0) + ); + } ); export const updateGatingStatus = zod(z.void(), async () => { @@ -85,22 +87,22 @@ export const updateGatingStatus = zod(z.void(), async () => { return false; } - const warnings = await Warning.forType({ - type: "permission_usage", - stageID: null, - }); - if (warnings.length) return true; - - // check usage - if (!customer?.subscriptionID) { - const startDate = DateTime.now().toUTC().startOf("day"); - const invocations = await countByStartAndEndDay({ - startDay: startDate.startOf("month").toSQLDate()!, - endDay: startDate.endOf("month").toSQLDate()!, + // note: only check for permission_usage warnings if the price is for invocations + if (customer?.priceID === Resource.StripeInvocationsPriceID.value) { + const warnings = await Warning.forType({ + type: "permission_usage", + stageID: null, }); - if (invocations > FREE_INVOCATIONS) return true; + if (warnings.length) return true; } - return false; + + if (customer?.priceID === Resource.StripeResourcesPriceID.value) + return false; + + const resources = await countResourcesByMonth({ + month: DateTime.utc().startOf("month").toSQLDate()!, + }); + return resources > FREE_RESOURCES; } const timeGated = (await isGated()) ? sql`NOW()` : null; @@ -110,6 +112,6 @@ export const updateGatingStatus = zod(z.void(), async () => { .update(workspace) .set({ timeGated }) .where(eq(workspace.id, useWorkspace())) - .execute(), + .execute() ); }); diff --git a/packages/core/src/billing/stripe.ts b/packages/core/src/billing/stripe.ts index a9c862b8..d7b62d92 100644 --- a/packages/core/src/billing/stripe.ts +++ b/packages/core/src/billing/stripe.ts @@ -1,6 +1,6 @@ -import { createSelectSchema } from "drizzle-zod"; import { z } from "zod"; -import { stripeTable } from "./billing.sql"; +import { Resource } from "sst"; +import { Standing, stripeTable } from "./billing.sql"; import { zod } from "../util/zod"; import { useTransaction } from "../util/transaction"; import { eq, and } from "drizzle-orm"; @@ -10,13 +10,44 @@ import { stripe } from "../stripe"; export * as Stripe from "./stripe"; -export const Info = createSelectSchema(stripeTable, { - customerID: (schema) => schema.customerID.trim().nonempty(), - subscriptionID: (schema) => schema.subscriptionID.trim().nonempty(), - subscriptionItemID: (schema) => schema.subscriptionItemID.trim().nonempty(), +export const Info = z.object({ + id: z.string().cuid2(), + customerID: z.string().optional(), + subscriptionID: z.string().optional(), + subscriptionItemID: z.string().optional(), + price: z.enum(["invocations", "resources"]).optional(), + standing: z.enum(Standing), + time: z.object({ + created: z.string(), + deleted: z.string().optional(), + updated: z.string(), + trialEnded: z.string().optional(), + }), }); export type Info = z.infer; +export function serialize(input: typeof stripeTable.$inferSelect): Info { + return { + id: input.id, + customerID: input.customerID ?? undefined, + subscriptionID: input.subscriptionID ?? undefined, + subscriptionItemID: input.subscriptionItemID ?? undefined, + price: + input.priceID === Resource.StripeInvocationsPriceID.value + ? ("invocations" as const) + : input.priceID === Resource.StripeResourcesPriceID.value + ? ("resources" as const) + : undefined, + standing: input.standing ?? "good", + time: { + created: input.timeCreated, + updated: input.timeUpdated, + deleted: input.timeDeleted ?? undefined, + trialEnded: input.timeTrialEnded ?? undefined, + }, + }; +} + export function get() { return useTransaction((tx) => tx @@ -74,10 +105,10 @@ export const fromCustomerID = zod(z.string(), (input) => ); export const setSubscription = zod( - Info.pick({ - subscriptionID: true, - subscriptionItemID: true, - priceID: true, + z.object({ + subscriptionID: z.string().min(1), + subscriptionItemID: z.string().min(1), + priceID: z.string().min(1), }), (input) => useTransaction((tx) => @@ -102,6 +133,7 @@ export const removeSubscription = zod( .set({ subscriptionItemID: null, subscriptionID: null, + priceID: null, }) .where(and(eq(stripeTable.subscriptionID, stripeSubscriptionID))) .execute() @@ -109,9 +141,9 @@ export const removeSubscription = zod( ); export const setStanding = zod( - Info.pick({ - subscriptionID: true, - standing: true, + z.object({ + subscriptionID: z.string().min(1), + standing: z.enum(Standing), }), (input) => useTransaction((tx) => diff --git a/packages/core/src/run/index.ts b/packages/core/src/run/index.ts index f9b972bb..c74163a5 100644 --- a/packages/core/src/run/index.ts +++ b/packages/core/src/run/index.ts @@ -67,6 +67,7 @@ import { AutodeployEmail } from "@console/mail/emails/templates/AutodeployEmail" import path from "path"; import { bus } from "sst/aws/bus"; import { githubOrgTable, githubRepoTable } from "../git/git.sql"; +import { Workspace } from "../workspace"; export { RunConfig } from "./config"; @@ -519,6 +520,9 @@ export module Run { force: z.boolean().optional(), }), async (input) => { + const workspace = await Workspace.fromID(useWorkspace()); + if (workspace?.timeGated) return; + const ref = input.trigger.type === "user" ? input.trigger.ref diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index 5a380636..e817832e 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -149,6 +149,19 @@ export module State { }); export type ResourceEvent = z.infer; + export const Count = z.object({ + id: z.string().cuid2(), + stageID: z.string().cuid2(), + month: z.string(), + count: z.number(), + time: z.object({ + created: z.string(), + deleted: z.string().optional(), + updated: z.string(), + }), + }); + export type Count = z.infer; + export function serializeUpdate( input: typeof stateUpdateTable.$inferSelect, ): Update { @@ -225,6 +238,22 @@ export module State { }; } + export function serializeCount( + input: typeof stateCountTable.$inferSelect, + ): Count { + return { + id: input.id, + time: { + created: input.timeCreated.toISOString(), + updated: input.timeUpdated.toISOString(), + deleted: input.timeDeleted?.toISOString(), + }, + stageID: input.stageID, + count: input.count, + month: input.month, + }; + } + export const receiveHistory = zod( z.object({ key: z.string(), diff --git a/packages/functions/package.json b/packages/functions/package.json index 0e24f5ae..43eadf97 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -28,6 +28,7 @@ "@console/mail": "workspace:*", "@esbuild/linux-arm64": "0.21.4", "@hono/zod-validator": "^0.4.1", + "@paralleldrive/cuid2": "2.2.2", "esbuild": "0.21.4", "fast-jwt": "^2.2.1", "hono": "4.6.5", diff --git a/packages/functions/src/api/billing.ts b/packages/functions/src/api/billing.ts index 21640151..e6f6b173 100644 --- a/packages/functions/src/api/billing.ts +++ b/packages/functions/src/api/billing.ts @@ -19,7 +19,7 @@ export const BillingRoute = new Hono() mode: "subscription", line_items: [ { - price: Resource.StripeInvocationsPriceID.value, + price: Resource.StripeResourcesPriceID.value, }, ], customer: item.customerID, diff --git a/packages/functions/src/api/replicache.ts b/packages/functions/src/api/replicache.ts index ae323bc1..8212d946 100644 --- a/packages/functions/src/api/replicache.ts +++ b/packages/functions/src/api/replicache.ts @@ -44,6 +44,7 @@ import { import { githubOrgTable, githubRepoTable } from "@console/core/git/git.sql"; import { slackTeam } from "@console/core/slack/slack.sql"; import { + stateCountTable, stateEventTable, stateResourceTable, stateUpdateTable, @@ -61,6 +62,7 @@ import { Hono } from "hono"; import { notPublic } from "./auth"; import { server } from "src/replicache/server"; import { VisibleError } from "@console/core/util/error"; +import { Billing } from "@console/core/billing"; export const ReplicacheRoute = new Hono().use(notPublic); @@ -68,6 +70,7 @@ export const TABLES = { stateUpdate: stateUpdateTable, stateResource: stateResourceTable, stateEvent: stateEventTable, + stateCount: stateCountTable, workspace, stripe: stripeTable, user, @@ -101,6 +104,7 @@ const TABLE_KEY = { issueCount: [issueCount.group, issueCount.id], warning: [warning.stageID, warning.type, warning.id], usage: [usage.stageID, usage.id], + stateCount: [stateCountTable.stageID, stateCountTable.id], stateUpdate: [stateUpdateTable.stageID, stateUpdateTable.id], stateResource: [stateResourceTable.stageID, stateResourceTable.id], stateEvent: [ @@ -125,12 +129,14 @@ const TABLE_SELECT = { const TABLE_PROJECTION = { alert: (input) => Alert.serialize(input), + stripe: (input) => Billing.Stripe.serialize(input), appRepo: (input) => AppRepo.serializeAppRepo(input), githubOrg: (input) => Github.serializeOrg(input), githubRepo: (input) => Github.serializeRepo(input), stateUpdate: (input) => State.serializeUpdate(input), stateEvent: (input) => State.serializeEvent(input), stateResource: (input) => State.serializeResource(input), + stateCount: (input) => State.serializeCount(input), runConfig: (input) => { if (!input.env) return input; for (const key of Object.keys(input.env)) { @@ -284,6 +290,10 @@ ReplicacheRoute.post("/pull1", async (c) => { stateUpdate: inArray(stateUpdateTable.id, updates), } : {}), + stateCount: gte( + stateCountTable.month, + DateTime.now().toUTC().startOf("month").toSQLDate()! + ), stateResource: deletedStages.length ? notInArray(stateResourceTable.stageID, deletedStages) : undefined, diff --git a/packages/functions/src/billing/cron.ts b/packages/functions/src/billing/cron.ts index f5cacb53..bcfdec59 100644 --- a/packages/functions/src/billing/cron.ts +++ b/packages/functions/src/billing/cron.ts @@ -3,13 +3,50 @@ import { SQSClient, SendMessageBatchCommand } from "@aws-sdk/client-sqs"; import { Resource } from "sst"; import { chunk } from "remeda"; import { createId } from "@console/core/util/sql"; +import { useTransaction } from "@console/core/util/transaction"; +import { stage } from "@console/core/app/app.sql"; +import { and, asc, eq, gt, isNull, or } from "@console/core/drizzle"; +import { workspace } from "@console/core/workspace/workspace.sql"; +import { stripeTable } from "@console/core/billing/billing.sql"; const sqs = new SQSClient({}); export async function handler() { - let cursor: Awaited>["cursor"]; + await handleInvocationsPricing(); + await handleResourcesPricing(); +} + +async function handleInvocationsPricing() { + let cursor: string | undefined; + + const listStages = async function (cursor?: string) { + return await useTransaction(async (tx) => { + const SIZE = 100000; + const items = await tx + .select({ + stageID: stage.id, + workspaceID: stage.workspaceID, + }) + .from(stage) + .innerJoin(stripeTable, eq(stripeTable.workspaceID, stage.workspaceID)) + .where( + and( + eq(stripeTable.priceID, Resource.StripeInvocationsPriceID.value), + cursor ? gt(stage.id, cursor) : undefined + ) + ) + .limit(SIZE) + .orderBy(asc(stage.id)) + .execute() + .then((rows) => rows); + return { + items, + cursor: items.length < SIZE ? undefined : items.at(-1)?.stageID, + }; + }); + }; do { - const ret = await Stage.list({ cursor }); + const ret = await listStages(cursor); const stages = ret.items; cursor = ret.cursor; @@ -23,12 +60,71 @@ export async function handler() { Id: createId(), MessageDeduplicationId: createId(), MessageBody: JSON.stringify({ - stageID: stage.id, + price: "invocations", + stageID: stage.stageID, workspaceID: stage.workspaceID, }), MessageGroupId: (index++ % 10).toString(), })), - }), + }) + ); + } + } while (cursor !== undefined); +} + +async function handleResourcesPricing() { + let cursor: string | undefined; + + const listWorkspaces = async function (cursor?: string) { + return await useTransaction(async (tx) => { + const SIZE = 100000; + const items = await tx + .select({ + workspaceID: workspace.id, + }) + .from(workspace) + .innerJoin(stripeTable, eq(stripeTable.workspaceID, workspace.id)) + .where( + and( + or( + isNull(stripeTable.priceID), + eq(stripeTable.priceID, Resource.StripeResourcesPriceID.value) + ), + cursor ? gt(workspace.id, cursor) : undefined + ) + ) + .limit(SIZE) + .orderBy(asc(workspace.id)) + .execute() + .then((rows) => rows); + return { + items, + cursor: items.length < SIZE ? undefined : items.at(-1)?.workspaceID, + }; + }); + }; + + do { + const ret = await listWorkspaces(cursor); + const workspaces = ret.items; + cursor = ret.cursor; + + console.log("workspaces", workspaces.length); + let index = 0; + for (const workspace of chunk(workspaces, 10)) { + await sqs.send( + new SendMessageBatchCommand({ + QueueUrl: Resource.BillingQueue.url, + Entries: workspace.map((workspace) => ({ + Id: createId(), + MessageDeduplicationId: createId(), + MessageBody: JSON.stringify({ + price: "resources", + workspaceID: workspace.workspaceID, + }), + MessageGroupId: (index++ % 10).toString(), + })), + }) ); } } while (cursor !== undefined); diff --git a/packages/functions/src/billing/fetch-usage.ts b/packages/functions/src/billing/fetch-usage.ts index d463b318..e0c38412 100644 --- a/packages/functions/src/billing/fetch-usage.ts +++ b/packages/functions/src/billing/fetch-usage.ts @@ -4,6 +4,7 @@ import { CloudWatchClient, GetMetricDataCommand, } from "@aws-sdk/client-cloudwatch"; +import { createId } from "@paralleldrive/cuid2"; import { withActor, useWorkspace } from "@console/core/actor"; import { Stage } from "@console/core/app/stage"; import { Billing } from "@console/core/billing"; @@ -12,9 +13,13 @@ import { Warning } from "@console/core/warning"; import { unique } from "remeda"; import { Workspace } from "@console/core/workspace"; import { usage } from "@console/core/billing/billing.sql"; -import { and, desc, eq, inArray } from "@console/core/drizzle"; +import { and, desc, eq, inArray, sql } from "@console/core/drizzle"; import { useTransaction } from "@console/core/util/transaction"; -import { stateResourceTable } from "@console/core/state/state.sql"; +import { + stateCountTable, + stateResourceTable, +} from "@console/core/state/state.sql"; +import { Resource } from "sst"; export async function handler(event: SQSEvent) { console.log("got", event.Records.length, "records"); @@ -29,19 +34,20 @@ export async function handler(event: SQSEvent) { }, }, async () => { - const { stageID } = evt; - - // Check if stage is unsupported - const stage = await Stage.fromID(stageID); - if (stage?.unsupported) return; - - await processStage(stageID); + if (evt.price === "invocations") { + await processInvocations(evt.stageID); + } else if (evt.price === "resources") { + await processResources(evt.workspaceID); + } } ); } } -async function processStage(stageID: string) { +async function processInvocations(stageID: string) { + const stage = await Stage.fromID(stageID); + if (stage?.unsupported) return; + const workspace = await Workspace.fromID(useWorkspace()); if (!workspace) return; @@ -157,11 +163,24 @@ async function processStage(stageID: string) { } hasChanges = hasChanges || invocations > 0; - await Billing.createUsage({ - stageID, - day: startDate.toSQLDate()!, - invocations, - }); + // Create usage + await useTransaction((tx) => + tx + .insert(usage) + .values({ + id: createId(), + workspaceID: useWorkspace(), + stageID, + day: startDate.toSQLDate()!, + invocations, + }) + .onDuplicateKeyUpdate({ + set: { + invocations, + }, + }) + .execute() + ); async function queryUsageFromAWS() { const client = new CloudWatchClient(config!); @@ -217,8 +236,9 @@ async function processStage(stageID: string) { async function reportUsageToStripe() { const item = await Billing.Stripe.get(); if (!item?.subscriptionItemID) return; + if (item?.priceID !== Resource.StripeInvocationsPriceID.value) return; - const monthlyInvocations = await Billing.countByStartAndEndDay({ + const monthlyInvocations = await Billing.countInvocationsByStartAndEndDay({ startDay: startDate.startOf("month").toSQLDate()!, endDay: startDate.endOf("month").toSQLDate()!, }); @@ -254,3 +274,57 @@ async function processStage(stageID: string) { } } } + +async function processResources(workspaceID: string) { + const workspace = await Workspace.fromID(workspaceID); + if (!workspace) return; + + const count = await Billing.countResourcesByMonth({ + month: DateTime.utc().startOf("month").toSQLDate()!, + }); + + console.log(`workspace ${workspaceID} has ${count} resources`); + + if (count > 0) await reportUsageToStripe(); + await Billing.updateGatingStatus(); + + ///////////////// + // Functions + ///////////////// + + async function reportUsageToStripe() { + const item = await Billing.Stripe.get(); + if (!item?.subscriptionItemID) return; + if (item?.priceID !== Resource.StripeResourcesPriceID.value) return; + + try { + //const timestamp = DateTime.utc().startOf("day").toUnixInteger(); + const timestamp = DateTime.utc().toUnixInteger(); + await stripe.subscriptionItems.createUsageRecord( + item.subscriptionItemID, + { + quantity: count, + timestamp, + action: "set", + }, + { + idempotencyKey: `${useWorkspace()}-${timestamp}`, + } + ); + } catch (e: any) { + console.log(e.message); + // TODO: aren't there instanceof checks we can do + if (e.message.startsWith("Keys for idempotent requests")) { + return; + } + if ( + e.message.startsWith( + "Cannot create the usage record with this timestamp" + ) + ) { + return; + } + throw e; + } + } +} diff --git a/packages/functions/src/replicache/dummy/data.ts b/packages/functions/src/replicache/dummy/data.ts index 01202584..1d122c1e 100644 --- a/packages/functions/src/replicache/dummy/data.ts +++ b/packages/functions/src/replicache/dummy/data.ts @@ -3285,9 +3285,11 @@ function stripe({ standing }: StripeProps): DummyData { subscriptionID: "sub_123", standing: standing || "good", subscriptionItemID: "sub_item_123", - priceID: "price_123", - timeDeleted: null, - timeTrialEnded: null, + price: "resources", + time: { + created: DateTime.now().startOf("day").toISO()!, + updated: DateTime.now().startOf("day").toISO()!, + }, ...timestamps, }; } diff --git a/packages/web/workspace/src/data/usage.ts b/packages/web/workspace/src/data/usage.ts index 86210bf4..8f962327 100644 --- a/packages/web/workspace/src/data/usage.ts +++ b/packages/web/workspace/src/data/usage.ts @@ -1,22 +1,35 @@ import type { Usage } from "@console/core/billing"; +import type { State } from "@console/core/state"; import { Store } from "./store"; type PricingTier = { from: number; to: number; rate: number; + base?: number; }; export type PricingPlan = PricingTier[]; -export const PRICING_PLAN: PricingPlan = [ +export const INVOCATIONS_PRICING_PLAN: PricingPlan = [ { from: 0, to: 1000000, rate: 0 }, { from: 1000000, to: 10000000, rate: 0.00002 }, { from: 10000000, to: Infinity, rate: 0.000002 }, ]; -export const UsageStore = new Store() +export const RESOURCES_PRICING_PLAN: PricingPlan = [ + { from: 0, to: 200, rate: 0 }, + { from: 200, to: 2000, rate: 0.1, base: 20 }, + { from: 2000, to: Infinity, rate: 0.03 }, +]; + +export const InvocationsUsageStore = new Store() .type() .scan("list", () => [`usage`]) .scan("forStage", (stageID: string) => [`usage`, stageID]) .build(); + +export const ResourcesUsageStore = new Store() + .type() + .scan("list", () => [`stateCount`]) + .build(); diff --git a/packages/web/workspace/src/pages/workspace/app/autodeploy/index.tsx b/packages/web/workspace/src/pages/workspace/app/autodeploy/index.tsx index d9883963..75733695 100644 --- a/packages/web/workspace/src/pages/workspace/app/autodeploy/index.tsx +++ b/packages/web/workspace/src/pages/workspace/app/autodeploy/index.tsx @@ -2,11 +2,36 @@ import { Route } from "@solidjs/router"; import { AutodeployNotFound } from "./not-found"; import { Detail } from "./detail"; import { List } from "./list"; +import { useApi, useWorkspace } from "../../context"; +import { GatedWarning } from "../warning"; +import { Fullscreen } from "$/ui/layout"; +import { Show } from "solid-js"; +import { PageHeader } from "../header"; export const Autodeploy = ( - <> + { + const api = useApi(); + const workspace = useWorkspace(); + return ( + + + + + + + } + > + {props.children} + + ); + }} + > - + ); diff --git a/packages/web/workspace/src/pages/workspace/app/settings/index.tsx b/packages/web/workspace/src/pages/workspace/app/settings/index.tsx index b57acf0a..cbc3e5d8 100644 --- a/packages/web/workspace/src/pages/workspace/app/settings/index.tsx +++ b/packages/web/workspace/src/pages/workspace/app/settings/index.tsx @@ -18,7 +18,7 @@ import { style } from "@macaron-css/core"; import type { RunConfig } from "@console/core/run/config"; import { styled } from "@macaron-css/solid"; import { useAppContext } from "../context"; -import { useWorkspace } from "../../context"; +import { useApi, useWorkspace } from "../../context"; import { useAuth2 } from "$/providers/auth2"; import { createId } from "@paralleldrive/cuid2"; import { IconEllipsisVertical } from "$/ui/icons"; @@ -53,12 +53,13 @@ import { createStore } from "solid-js/store"; import { fromEntries, map, pipe, sortBy, filter } from "remeda"; import { TextButton, ButtonIcon } from "$/ui/button"; import { FormField, Input } from "$/ui/form"; -import { Grower, Row, Stack } from "$/ui/layout"; +import { Fullscreen, Grower, Row, Stack } from "$/ui/layout"; import { Tag } from "$/ui/tag"; import { theme } from "$/ui/theme"; import { utility } from "$/ui/utility"; import { Text } from "$/ui/text"; import { Button } from "$/ui/button"; +import { GatedWarning } from "../warning"; const HEADER_HEIGHT = 54; @@ -418,10 +419,10 @@ export const EditTargetForm = v.object({ key: v.pipe(v.string(), v.minLength(1, "Set the key of the variable")), value: v.pipe( v.string(), - v.minLength(1, "Set the value of the variable"), + v.minLength(1, "Set the value of the variable") ), - }), - ), + }) + ) ), }); @@ -433,21 +434,22 @@ const EditRepoForm = v.object({ export function Settings() { const auth = useAuth2(); const rep = useReplicache(); + const api = useApi(); const app = useAppContext(); const workspace = useWorkspace(); const runConfigs = createSubscription( () => (tx) => RunConfigStore.forApp(tx, app.app.id), - [], + [] ); const appRepo = createSubscription( - () => (tx) => AppRepoStore.forApp(tx, app.app.id).then((repos) => repos[0]), + () => (tx) => AppRepoStore.forApp(tx, app.app.id).then((repos) => repos[0]) ); const needsGithub = createSubscription(() => async (tx) => { const ghOrgs = await GithubOrgStore.all(tx); const appRepo = await AppRepoStore.forApp(tx, app.app.id).then( - (repos) => repos[0], + (repos) => repos[0] ); if (appRepo) { const ghRepo = await GithubRepoStore.get(tx, appRepo.repoID); @@ -479,7 +481,7 @@ export function Settings() { "message", (e) => { if (e.data === "github.success") setOverrideGithub(true); - }, + } ); const [putForm, { Form, Field, FieldArray }] = createForm({ @@ -557,7 +559,7 @@ export function Settings() { onSelect={() => { if ( !confirm( - "Are you sure you want to remove this environment?", + "Are you sure you want to remove this environment?" ) ) return; @@ -589,7 +591,7 @@ export function Settings() { awsAccountExternalID: data.awsAccount, appID: app.app.id, env: fromEntries( - (data.env || []).map((item) => [item.key, item.value]), + (data.env || []).map((item) => [item.key, item.value]) ), }); setEditing("active", false); @@ -712,16 +714,16 @@ export function Settings() { onPaste={(e) => { const data = e.clipboardData?.getData( - "text/plain", + "text/plain" ); if (!data) return; setValue( putForm, `env.${index()}.value`, - data, + data ); e.currentTarget.value = "0".repeat( - data.length, + data.length ); e.preventDefault(); }} @@ -837,8 +839,8 @@ export function Settings() { new Set( orgs.value .filter((org) => !org.time.disconnected) - .map((org) => org.id), - ), + .map((org) => org.id) + ) ); const sortedRepos = createMemo(() => pipe( @@ -848,13 +850,13 @@ export function Settings() { label: repo.name, value: repo.id, })), - sortBy((repo) => repo.label), - ), + sortBy((repo) => repo.label) + ) ); const newRepo = createMemo(() => props.new === true); const empty = createMemo(() => sortedRepos().length === 0); const expanded = createMemo(() => - newRepo() ? !empty() && !!getValue(repoForm, "repo") : true, + newRepo() ? !empty() && !!getValue(repoForm, "repo") : true ); return ( @@ -964,250 +966,265 @@ export function Settings() { return ( <> - - - - {app.app.name} - - - View and manage your app's settings - - - - + + + + } + > + - - Autodeploy + + {app.app.name} - - Push to your GitHub repo to auto-deploy your app + + View and manage your app's settings - - - - - - - + + + + Autodeploy + + + Push to your GitHub repo to auto-deploy your app + + + + + + + + + + Start by connecting to your GitHub organization + + +
- Start by connecting to your GitHub organization - - - - - - - -
-
-
- - - {(_item) => { - const info = createSubscription(() => async (tx) => { - const repo = await GithubRepoStore.get( - tx, - appRepo.value!.repoID, - ); - const org = await GithubOrgStore.get( - tx, - repo.githubOrgID, - ); - return { - org, - repo, - }; - }); - return ( - - - - - - - - - - - - - - - {info.value!.org.login} - - / - - {info.value!.repo.name} - - - Deploying path: {appRepo.value!.path} - - - - - + + + + +
+
+ + + {(_item) => { + const info = createSubscription(() => async (tx) => { + const repo = await GithubRepoStore.get( + tx, + appRepo.value!.repoID + ); + const org = await GithubOrgStore.get( + tx, + repo.githubOrgID + ); + return { + org, + repo, + }; + }); + return ( + + + + + + + + + + + + + + + {info.value!.org.login} + + / + + {info.value!.repo.name} + + + Deploying path: {appRepo.value!.path} + + + + + - - } - > - { - setEditingRepo("id", appRepo.value!.id); - setEditingRepo("active", true); + return; + rep().mutate.app_repo_disconnect( + appRepo.value!.id + ); reset(repoForm, { - initialValues: { - repo: appRepo.value!.repoID, - path: appRepo.value!.path, - }, + initialValues: repoFormInitialValues, }); }} > - Edit path - - - - - - - - - - Environments - - Learn about environments - - -
- val.stagePattern.length), - )} - > - {(config) => ( - <> - - - - - - )} - - - - - - Add new environment - - - - - - - - - { - addBranchConfig(); - }} - > - - - - Branch environment - - - c.stagePattern.startsWith("pr-"), - ) - } - > - + Disconnect + + + } + > + { + setEditingRepo( + "id", + appRepo.value!.id + ); + setEditingRepo("active", true); + reset(repoForm, { + initialValues: { + repo: appRepo.value!.repoID, + path: appRepo.value!.path, + }, + }); + }} + > + Edit path + + + + + + + + + + Environments + + Learn about environments + + +
+ val.stagePattern.length) + )} + > + {(config) => ( + <> + + + + + + )} + + + + + + Add new environment + + + + + + + + { - addPrConfig(); + addBranchConfig(); }} > - PR environment + Branch environment - - - - -
-
-
- ); - }} - - - - - - - - - - + + c.stagePattern.startsWith("pr-") + ) + } + > + + { + addPrConfig(); + }} + > + + + + PR environment + + +
+
+
+
+
+
+ ); + }} +
+ + + + +
+
+
+
+
+ ); } diff --git a/packages/web/workspace/src/pages/workspace/app/warning.tsx b/packages/web/workspace/src/pages/workspace/app/warning.tsx new file mode 100644 index 00000000..f3ac8c6c --- /dev/null +++ b/packages/web/workspace/src/pages/workspace/app/warning.tsx @@ -0,0 +1,85 @@ +import { JSX } from "solid-js"; +import { styled } from "@macaron-css/solid"; +import { IconExclamationTriangle } from "$/ui/icons"; +import { Stack } from "$/ui/layout"; +import { theme } from "$/ui/theme"; +import { utility } from "$/ui/utility"; +import { useWorkspace } from "../context"; +import { A } from "@solidjs/router"; + +const WarningRoot = styled("div", { + base: { + ...utility.stack(8), + marginTop: "-7vh", + alignItems: "center", + width: 400, + }, +}); + +const WarningIcon = styled("div", { + base: { + width: 42, + height: 42, + color: theme.color.icon.dimmed, + }, +}); + +const WarningTitle = styled("span", { + base: { + ...utility.text.line, + lineHeight: "normal", + fontSize: theme.font.size.lg, + fontWeight: theme.font.weight.medium, + }, +}); + +const WarningDescription = styled("span", { + base: { + textAlign: "center", + fontSize: theme.font.size.sm, + lineHeight: theme.font.lineHeight, + color: theme.color.text.secondary.base, + }, +}); + +interface WarningProps { + title: JSX.Element; + description: JSX.Element; +} + +export function Warning(props: WarningProps) { + return ( + + + + + + + {props.title} + {props.description} + + + + ); +} + +export function GatedWarning() { + const workspace = useWorkspace(); + return ( + + Your usage is above the free tier,{" "} + + update your billing details + + .
+ Note, you can continue using the Console for local stages. +
+ Just make sure `sst dev` is running locally. + + } + /> + ); +} diff --git a/packages/web/workspace/src/pages/workspace/context.ts b/packages/web/workspace/src/pages/workspace/context.ts index 453767f5..940e1b27 100644 --- a/packages/web/workspace/src/pages/workspace/context.ts +++ b/packages/web/workspace/src/pages/workspace/context.ts @@ -2,8 +2,11 @@ import { createInitializedContext } from "$/common/context"; import { useAuth2 } from "$/providers/auth2"; import { Workspace } from "@console/core/workspace"; import { app } from "@console/functions/api/api"; +import { useReplicache } from "$/providers/replicache"; +import { RESOURCES_PRICING_PLAN, ResourcesUsageStore } from "$/data/usage"; import { hc } from "hono/client"; import { Accessor, createContext, useContext } from "solid-js"; +import { sumBy } from "remeda"; export const WorkspaceContext = createContext>(); @@ -16,8 +19,14 @@ export function useWorkspace() { export const { use: useApi, provider: ApiProvider } = createInitializedContext( "Api", () => { + const rep = useReplicache(); const auth = useAuth2(); const workspace = useWorkspace(); + const usage = ResourcesUsageStore.list.watch( + rep, + () => [], + (items) => sumBy(items, (item) => item.count) + ); const client = hc(import.meta.env.VITE_API_URL, { headers: { Authorization: `Bearer ${auth.current.token}`, @@ -27,6 +36,9 @@ export const { use: useApi, provider: ApiProvider } = createInitializedContext( return { client, ready: true, + get isFree() { + return usage() <= RESOURCES_PRICING_PLAN[0].to; + }, }; - }, + } ); diff --git a/packages/web/workspace/src/pages/workspace/settings/index.tsx b/packages/web/workspace/src/pages/workspace/settings/index.tsx index 1bf5a672..fb99e7fd 100644 --- a/packages/web/workspace/src/pages/workspace/settings/index.tsx +++ b/packages/web/workspace/src/pages/workspace/settings/index.tsx @@ -1,13 +1,25 @@ -import { Show, createMemo, createSignal, Suspense } from "solid-js"; +import { + Show, + createMemo, + createSignal, + Suspense, + createEffect, +} from "solid-js"; import { DateTime } from "luxon"; import { styled } from "@macaron-css/solid"; -import { useApi, useWorkspace } from "../context"; +import { useWorkspace } from "../context"; import { utility } from "$/ui/utility"; import { Toggle } from "$/ui/switch"; import { IconLogosSlack, IconLogosGitHub } from "$/ui/icons/custom"; import { formatNumber } from "$/common/format"; import { useReplicache } from "$/providers/replicache"; -import { PRICING_PLAN, PricingPlan, UsageStore } from "$/data/usage"; +import { + INVOCATIONS_PRICING_PLAN, + RESOURCES_PRICING_PLAN, + PricingPlan, + InvocationsUsageStore, + ResourcesUsageStore, +} from "$/data/usage"; import { Header } from "../header"; import { SlackTeamStore, StripeStore, GithubOrgStore } from "$/data/app"; import { createEventListener } from "@solid-primitives/event-listener"; @@ -30,10 +42,10 @@ function calculateCost(units: number, pricingPlan: PricingPlan) { for (let tier of pricingPlan) { if (units > tier.from) { if (units < tier.to) { - cost += (units - tier.from) * tier.rate; + cost += (tier.base ?? 0) + (units - tier.from) * tier.rate; break; } else { - cost += (tier.to - tier.from) * tier.rate; + cost += (tier.base ?? 0) + (tier.to - tier.from) * tier.rate; } } } @@ -119,26 +131,40 @@ const UsageStatTier = styled("span", { }, }); +const UsagePlanCopy = styled("p", { + base: { + fontSize: theme.font.size.sm, + color: theme.color.text.secondary.base, + lineHeight: theme.font.lineHeight, + }, +}); + export function SettingsRoute() { const rep = useReplicache(); - const usages = UsageStore.list.watch(rep, () => []); + const invocationsUsages = InvocationsUsageStore.list.watch(rep, () => []); + const resourcesUsages = ResourcesUsageStore.list.watch(rep, () => []); const invocations = createMemo(() => - usages() + invocationsUsages() .map((usage) => usage.invocations) .reduce((a, b) => a + b, 0), ); - const api = useApi(); - console.log("usages", usages().length); + const resources = createMemo(() => + resourcesUsages() + .map((usage) => usage.count) + .reduce((a, b) => a + b, 0), + ); const auth = useAuth2(); + const nav = useNavigate(); const workspace = useWorkspace(); const cycle = createMemo(() => { - const data = usages(); + const data = invocationsUsages(); const start = data[0] ? DateTime.fromSQL(data[0].day) : DateTime.now(); return { start: start.startOf("month").toFormat("LLL d"), end: start.endOf("month").toFormat("LLL d"), }; }); + const stripe = StripeStore.get.watch(rep, () => []); let portalLink: Promise | undefined; let checkoutLink: Promise | undefined; @@ -191,9 +217,6 @@ export function SettingsRoute() { window.location.href = result.url; } - const stripe = StripeStore.get.watch(rep, () => []); - const nav = useNavigate(); - return (
@@ -218,77 +241,167 @@ export function SettingsRoute() { Usage for the current billing period - - - - - Invocations - - - {invocations()} - - - - - Current Cost - - - - $ - - - {calculateCost(invocations(), PRICING_PLAN)} - - - - - - - - {formatNumber(PRICING_PLAN[0].from)} -{" "} - {formatNumber(PRICING_PLAN[0].to)} - - - → + + + + + + + Invocations - - Free + + {invocations()} - - - - {formatNumber(PRICING_PLAN[1].from)} -{" "} - {formatNumber(PRICING_PLAN[1].to)} - - - → - - - ${PRICING_PLAN[1].rate} per + + + + Current Cost - - - - {formatNumber(PRICING_PLAN[2].from)} + - - - → + + + $ + + + {calculateCost(invocations(), INVOCATIONS_PRICING_PLAN)} + + + + + + + + {formatNumber(INVOCATIONS_PRICING_PLAN[0].from)} -{" "} + {formatNumber(INVOCATIONS_PRICING_PLAN[0].to)} + + + → + + + Free + + + + + {formatNumber(INVOCATIONS_PRICING_PLAN[1].from)} -{" "} + {formatNumber(INVOCATIONS_PRICING_PLAN[1].to)} + + + → + + + ${INVOCATIONS_PRICING_PLAN[1].rate} per + + + + + {formatNumber(INVOCATIONS_PRICING_PLAN[2].from)} + + + + → + + + ${INVOCATIONS_PRICING_PLAN[2].rate} per + + + + + + + Below is the new pricing plan. If you'd like to switch, you + can unsubscribe from the current plan and resubscribe.{" "} + + Learn more + + . + + + + + + + + Resources + + + {resources()} + + + + + {stripe()?.price === "invocations" + ? "New Cost" + : "Current Cost"} + + + + $ - - ${PRICING_PLAN[2].rate} per + + {calculateCost(resources(), RESOURCES_PRICING_PLAN)} - - - - - Calculated for the period of {cycle().start} — {cycle().end}.{" "} - - Learn more - {" "} - or contact us for volume - pricing. - + + + + + + {"<= "} + {formatNumber(RESOURCES_PRICING_PLAN[0].to)} + + + → + + + Free + + + + + {formatNumber(RESOURCES_PRICING_PLAN[0].from)} -{" "} + {formatNumber(RESOURCES_PRICING_PLAN[1].to)} + + + → + + + ${RESOURCES_PRICING_PLAN[1].rate} per + + + + + {formatNumber(RESOURCES_PRICING_PLAN[2].from)} + + + + → + + + ${RESOURCES_PRICING_PLAN[2].rate} per + + + + + + + Calculated for the period of {cycle().start} — {cycle().end}.{" "} + + Learn more + + . + + @@ -325,7 +438,7 @@ export function SettingsRoute() { > Add Billing Details - PRICING_PLAN[0].to}> + INVOCATIONS_PRICING_PLAN[0].to}> Your current usage is above the free tier. Please add your billing details. diff --git a/packages/web/workspace/src/pages/workspace/stage/context.tsx b/packages/web/workspace/src/pages/workspace/stage/context.tsx index 248a6f33..d71e8f9d 100644 --- a/packages/web/workspace/src/pages/workspace/stage/context.tsx +++ b/packages/web/workspace/src/pages/workspace/stage/context.tsx @@ -1,9 +1,5 @@ import { useReplicache } from "$/providers/replicache"; -import { - createContext, - createMemo, - useContext, -} from "solid-js"; +import { createContext, createMemo, useContext } from "solid-js"; import { useNavigate, useParams } from "@solidjs/router"; import { StageStore } from "$/data/stage"; import { AppStore, StateResourceStore } from "$/data/app"; @@ -11,17 +7,7 @@ import { NavigationAction, useCommandBar } from "../command-bar"; import { useLocalContext } from "$/providers/local"; import { createInitializedContext } from "$/common/context"; import { IssueStore } from "$/data/issue"; -import { UsageStore } from "$/data/usage"; -import { - flatMap, - groupBy, - map, - mapValues, - pipe, - sortBy, - sumBy, - values, -} from "remeda"; +import { flatMap, groupBy, map, mapValues, pipe, sortBy, values } from "remeda"; import { useWorkspace } from "../context"; export const StageContext = @@ -33,7 +19,7 @@ export function createStageContext() { const app = AppStore.all.watch( rep, () => [], - (items) => items.find((app) => app.name === params.appName), + (items) => items.find((app) => app.name === params.appName) ); const stage = StageStore.list.watch( rep, @@ -43,15 +29,10 @@ export function createStageContext() { (stage) => stage.appID === app()?.id && !stage.timeDeleted && - (stage.name === params.stageName || stage.id === params.stageName), - ), + (stage.name === params.stageName || stage.id === params.stageName) + ) ); const local = useLocalContext(); - const usage = UsageStore.forStage.watch( - rep, - () => [stage()?.id || "unknown"], - (items) => sumBy(items, (item) => item.invocations), - ); return { get ready() { @@ -70,9 +51,6 @@ export function createStageContext() { (!local.region || stage()?.region === local.region) ); }, - get isFree() { - return usage() < 1_000_000; - }, }; } @@ -108,7 +86,7 @@ export const { use: useStateResources, provider: StateResourcesProvider } = path: `resources/${encodeURIComponent(resource.urn)}`, title: resource.urn.split("::").at(-1)! + " (" + resource.type + ")", category: "resource", - }), + }) ); }); return resources; @@ -156,7 +134,7 @@ export const { use: useLogsContext, provider: LogsProvider } = const lambda = resources().find( (child) => child.type === "aws:lambda/function:Function" && - child.parent === r.urn, + child.parent === r.urn ); const logGroup = lambda?.outputs?.loggingConfig?.logGroup; const dev = lambda?.outputs?.description?.includes("live"); @@ -196,7 +174,7 @@ export const { use: useLogsContext, provider: LogsProvider } = const logGroup = resources().find( (child) => child.type === "aws:cloudwatch/logGroup:LogGroup" && - child.parent === r.urn, + child.parent === r.urn )?.outputs?.id; return [ @@ -220,10 +198,11 @@ export const { use: useLogsContext, provider: LogsProvider } = map((item) => ({ ...item, link: - `/${workspace().slug}/${stage.app.name}/${stage.stage.name}/logs/aws?` + - item.link, - })), - ), + `/${workspace().slug}/${stage.app.name}/${ + stage.stage.name + }/logs/aws?` + item.link, + })) + ) ); const bar = useCommandBar(); @@ -235,7 +214,7 @@ export const { use: useLogsContext, provider: LogsProvider } = path: item.link, title: item.title, category: "logs", - }), + }) ); }); diff --git a/packages/web/workspace/src/pages/workspace/stage/index.tsx b/packages/web/workspace/src/pages/workspace/stage/index.tsx index d54ef4a1..9150eaa2 100644 --- a/packages/web/workspace/src/pages/workspace/stage/index.tsx +++ b/packages/web/workspace/src/pages/workspace/stage/index.tsx @@ -1,5 +1,5 @@ import { A, Navigate, Route, useNavigate } from "@solidjs/router"; -import { JSX, Match, Show, Switch, createMemo } from "solid-js"; +import { Match, Show, Switch, createMemo } from "solid-js"; import { StateUpdateStore } from "$/data/app"; import { NavigationAction, useCommandBar } from "$/pages/workspace/command-bar"; import { useReplicache } from "$/providers/replicache"; @@ -23,20 +23,20 @@ import { HeaderProvider, useHeaderContext, } from "../header"; -import { IconExclamationTriangle } from "$/ui/icons"; -import { styled } from "@macaron-css/solid"; import { NotFound } from "../../not-found"; import { TabTitle } from "$/ui/button"; -import { Stack, Row } from "$/ui/layout"; -import { theme } from "$/ui/theme"; -import { utility } from "$/ui/utility"; +import { Row, Fullscreen } from "$/ui/layout"; +import { useApi, useWorkspace } from "../context"; +import { GatedWarning } from "../app/warning"; export const StageRoute = ( { + const api = useApi(); const ctx = createStageContext(); const rep = useReplicache(); const header = useHeaderContext(); + const workspace = useWorkspace(); return ( @@ -49,15 +49,15 @@ export const StageRoute = ( {(() => { const updates = StateUpdateStore.forStage.watch( rep, - () => [ctx.stage.id], + () => [ctx.stage.id] ); const issues = useIssuesContext(); const issuesCount = createMemo( () => issues().filter( (item) => - !item.timeResolved && !item.timeIgnored, - ).length, + !item.timeResolved && !item.timeIgnored + ).length ); return ( <> @@ -73,6 +73,17 @@ export const StageRoute = ( message="Stage has been removed" /> + + + + + @@ -140,61 +151,6 @@ export const StageRoute = ( ); -const WarningRoot = styled("div", { - base: { - ...utility.stack(8), - marginTop: "-7vh", - alignItems: "center", - width: 400, - }, -}); - -const WarningIcon = styled("div", { - base: { - width: 42, - height: 42, - color: theme.color.icon.dimmed, - }, -}); - -const WarningTitle = styled("span", { - base: { - ...utility.text.line, - lineHeight: "normal", - fontSize: theme.font.size.lg, - fontWeight: theme.font.weight.medium, - }, -}); - -const WarningDescription = styled("span", { - base: { - textAlign: "center", - fontSize: theme.font.size.sm, - lineHeight: theme.font.lineHeight, - color: theme.color.text.secondary.base, - }, -}); - -interface WarningProps { - title: JSX.Element; - description: JSX.Element; -} -export function Warning(props: WarningProps) { - return ( - - - - - - - {props.title} - {props.description} - - - - ); -} - export function Commands() { const bar = useCommandBar(); const ctx = useStageContext(); diff --git a/packages/web/workspace/src/pages/workspace/stage/issues/index.tsx b/packages/web/workspace/src/pages/workspace/stage/issues/index.tsx index eed45c48..0cf8ab5a 100644 --- a/packages/web/workspace/src/pages/workspace/stage/issues/index.tsx +++ b/packages/web/workspace/src/pages/workspace/stage/issues/index.tsx @@ -1,50 +1,10 @@ -import { A, Route } from "@solidjs/router"; -import { Match, Switch } from "solid-js"; +import { Route } from "@solidjs/router"; import { List } from "./list"; import { Detail } from "./detail"; -import { Warning } from "../"; import { NotFound } from "../../../not-found"; -import { useWorkspace } from "../../context"; -import { useStageContext } from "../context"; -import { Fullscreen } from "$/ui/layout"; export const Issues = ( - { - const ctx = useStageContext(); - const workspace = useWorkspace(); - return ( - <> - - - - - Your usage is above the free tier,{" "} - - update your billing details - - .
- Note, you can continue using the Console for local stages. -
- Just make sure `sst dev` is running locally. - - } - /> -
-
- {props.children} -
- - ); - }} - > + } /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b95d76fa..85841325 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,9 @@ importers: '@hono/zod-validator': specifier: ^0.4.1 version: 0.4.2(hono@4.6.5)(zod@3.24.1) + '@paralleldrive/cuid2': + specifier: 2.2.2 + version: 2.2.2 esbuild: specifier: 0.21.4 version: 0.21.4 @@ -7176,7 +7179,7 @@ snapshots: '@aws-sdk/credential-provider-http': 3.723.0 '@aws-sdk/credential-provider-process': 3.723.0 '@aws-sdk/credential-provider-sso': 3.726.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1(aws-crt@1.25.0))(aws-crt@1.25.0))(aws-crt@1.25.0) - '@aws-sdk/credential-provider-web-identity': 3.723.0(@aws-sdk/client-sts@3.726.1(aws-crt@1.25.0)) + '@aws-sdk/credential-provider-web-identity': 3.723.0(@aws-sdk/client-sts@3.699.0(aws-crt@1.25.0)) '@aws-sdk/types': 3.723.0 '@smithy/credential-provider-imds': 4.0.1 '@smithy/property-provider': 4.0.1 @@ -7608,7 +7611,7 @@ snapshots: '@aws-sdk/token-providers@3.723.0(@aws-sdk/client-sso-oidc@3.726.0(@aws-sdk/client-sts@3.726.1(aws-crt@1.25.0))(aws-crt@1.25.0))': dependencies: - '@aws-sdk/client-sso-oidc': 3.726.0(@aws-sdk/client-sts@3.726.1(aws-crt@1.25.0))(aws-crt@1.25.0) + '@aws-sdk/client-sso-oidc': 3.726.0(@aws-sdk/client-sts@3.699.0(aws-crt@1.25.0))(aws-crt@1.25.0) '@aws-sdk/types': 3.723.0 '@smithy/property-provider': 4.0.1 '@smithy/shared-ini-file-loader': 4.0.1 diff --git a/sst-env.d.ts b/sst-env.d.ts index 7ce1b581..243ff101 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -9,58 +9,10 @@ declare module "sst" { "arn": string "type": "sst.aws.SnsTopic" } - "Api": { - "name": string - "type": "sst.aws.Function" - "url": string - } - "ApiRouter": { - "type": "sst.aws.Router" - "url": string - } "Auth": { "publicKey": string "type": "sst.aws.Auth" } - "AuthAuthenticator": { - "name": string - "type": "sst.aws.Function" - "url": string - } - "AuthRouter": { - "type": "sst.aws.Router" - "url": string - } - "AutodeployConfig": { - "buildImage": string - "buildspecBucketName": string - "buildspecVersion": string - "configParserFunctionArn": string - "runnerRemoverFunctionArn": string - "runnerRemoverScheduleGroupName": string - "runnerRemoverScheduleRoleArn": string - "timeoutMonitorFunctionArn": string - "timeoutMonitorScheduleGroupName": string - "timeoutMonitorScheduleRoleArn": string - "type": "sst.sst.Linkable" - } - "AutodeployConfigParser": { - "name": string - "type": "sst.aws.Function" - } - "AutodeployRunnerRemover": { - "name": string - "type": "sst.aws.Function" - } - "AutodeployTimeoutMonitor": { - "name": string - "type": "sst.aws.Function" - } - "Backend": { - "service": string - "type": "sst.aws.Service" - "url": string - } "BillingQueue": { "type": "sst.aws.Queue" "url": string @@ -74,10 +26,6 @@ declare module "sst" { "name": string "type": "sst.aws.Bus" } - "Connect": { - "name": string - "type": "sst.aws.Function" - } "Database": { "database": string "host": string @@ -91,11 +39,6 @@ declare module "sst" { "sender": string "type": "sst.aws.Email" } - "Error": { - "name": string - "type": "sst.aws.Function" - "url": string - } "GithubAppID": { "type": "sst.sst.Secret" "value": string @@ -159,10 +102,6 @@ declare module "sst" { "token": string "type": "sst.sst.Linkable" } - "WebsocketAuthorizer": { - "name": string - "type": "sst.aws.Function" - } "WebsocketToken": { "type": "sst.sst.Secret" "value": string