diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 86678bc1ca..3e3c99ca96 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1,4 +1,5 @@ import { + AdjustmentsHorizontalIcon, ArrowPathRoundedSquareIcon, ArrowRightOnRectangleIcon, BeakerIcon, @@ -50,6 +51,7 @@ import { adminPath, branchesPath, concurrencyPath, + limitsPath, logoutPath, newOrganizationPath, newProjectPath, @@ -349,7 +351,7 @@ export function SideMenu({ @@ -357,11 +359,18 @@ export function SideMenu({ } /> + + createRedisClient("trigger:rateLimitQuery", { + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }) +); + +// Types for rate limit display +export type RateLimitInfo = { + name: string; + description: string; + config: RateLimiterConfig; + currentTokens: number | null; +}; + +// Types for quota display +export type QuotaInfo = { + name: string; + description: string; + limit: number | null; + currentUsage: number; + source: "default" | "plan" | "override"; + canExceed?: boolean; + isUpgradable?: boolean; +}; + +// Types for feature flags +export type FeatureInfo = { + name: string; + description: string; + enabled: boolean; + value?: string | number; +}; + +export type LimitsResult = { + rateLimits: { + api: RateLimitInfo; + batch: RateLimitInfo; + }; + quotas: { + projects: QuotaInfo; + schedules: QuotaInfo | null; + teamMembers: QuotaInfo | null; + alerts: QuotaInfo | null; + branches: QuotaInfo | null; + logRetentionDays: QuotaInfo | null; + realtimeConnections: QuotaInfo | null; + devQueueSize: QuotaInfo; + deployedQueueSize: QuotaInfo; + }; + batchConcurrency: { + limit: number; + source: "default" | "override"; + }; + features: { + hasStagingEnvironment: FeatureInfo; + support: FeatureInfo; + includedUsage: FeatureInfo; + }; + planName: string | null; + organizationId: string; + isOnTopPlan: boolean; +}; + +export class LimitsPresenter extends BasePresenter { + public async call({ + userId, + projectId, + organizationId, + environmentApiKey, + }: { + userId: string; + projectId: string; + organizationId: string; + environmentApiKey: string; + }): Promise { + // Get organization with all limit-related fields + const organization = await this._replica.organization.findUniqueOrThrow({ + where: { id: organizationId }, + select: { + id: true, + maximumConcurrencyLimit: true, + maximumProjectCount: true, + maximumDevQueueSize: true, + maximumDeployedQueueSize: true, + apiRateLimiterConfig: true, + batchRateLimitConfig: true, + batchQueueConcurrencyConfig: true, + _count: { + select: { + projects: { + where: { deletedAt: null }, + }, + members: true, + }, + }, + }, + }); + + // Get current plan from billing service + const currentPlan = await getCurrentPlan(organizationId); + const limits = currentPlan?.v3Subscription?.plan?.limits; + const isOnTopPlan = currentPlan?.v3Subscription?.plan?.code === "v3_pro_1"; + + // Resolve rate limit configs (org override or default) + const apiRateLimitConfig = resolveApiRateLimitConfig(organization.apiRateLimiterConfig); + const batchRateLimitConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); + + // Resolve batch concurrency config + const batchConcurrencyConfig = resolveBatchConcurrencyConfig( + organization.batchQueueConcurrencyConfig + ); + const batchConcurrencySource = organization.batchQueueConcurrencyConfig + ? "override" + : "default"; + + // Get schedule count for this org + const scheduleCount = await this._replica.taskSchedule.count({ + where: { + instances: { + some: { + environment: { + organizationId, + }, + }, + }, + }, + }); + + // Get alert channel count for this org + const alertChannelCount = await this._replica.projectAlertChannel.count({ + where: { + project: { + organizationId, + }, + }, + }); + + // Get active branches count for this org + const activeBranchCount = await this._replica.runtimeEnvironment.count({ + where: { + project: { + organizationId, + }, + branchName: { + not: null, + }, + archivedAt: null, + }, + }); + + // Get current rate limit tokens for this environment's API key + const apiRateLimitTokens = await getRateLimitRemainingTokens( + "api", + environmentApiKey, + apiRateLimitConfig + ); + const batchRateLimitTokens = await getRateLimitRemainingTokens( + "batch", + environmentApiKey, + batchRateLimitConfig + ); + + // Get plan-level limits + const schedulesLimit = limits?.schedules?.number ?? null; + const teamMembersLimit = limits?.teamMembers?.number ?? null; + const alertsLimit = limits?.alerts?.number ?? null; + const branchesLimit = limits?.branches?.number ?? null; + const logRetentionDaysLimit = limits?.logRetentionDays?.number ?? null; + const realtimeConnectionsLimit = limits?.realtimeConcurrentConnections?.number ?? null; + const includedUsage = limits?.includedUsage ?? null; + const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false; + const supportLevel = limits?.support ?? "community"; + + return { + isOnTopPlan, + rateLimits: { + api: { + name: "API rate limit", + description: "Rate limit for API requests (trigger, batch, etc.)", + config: apiRateLimitConfig, + currentTokens: apiRateLimitTokens, + }, + batch: { + name: "Batch rate limit", + description: "Rate limit for batch trigger operations", + config: batchRateLimitConfig, + currentTokens: batchRateLimitTokens, + }, + }, + quotas: { + projects: { + name: "Projects", + description: "Maximum number of projects in this organization", + limit: organization.maximumProjectCount, + currentUsage: organization._count.projects, + source: "default", + isUpgradable: true, + }, + schedules: + schedulesLimit !== null + ? { + name: "Schedules", + description: "Maximum number of schedules across all projects", + limit: schedulesLimit, + currentUsage: scheduleCount, + source: "plan", + canExceed: limits?.schedules?.canExceed, + isUpgradable: true, + } + : null, + teamMembers: + teamMembersLimit !== null + ? { + name: "Team members", + description: "Maximum number of team members in this organization", + limit: teamMembersLimit, + currentUsage: organization._count.members, + source: "plan", + canExceed: limits?.teamMembers?.canExceed, + isUpgradable: true, + } + : null, + alerts: + alertsLimit !== null + ? { + name: "Alert channels", + description: "Maximum number of alert channels across all projects", + limit: alertsLimit, + currentUsage: alertChannelCount, + source: "plan", + canExceed: limits?.alerts?.canExceed, + isUpgradable: true, + } + : null, + branches: + branchesLimit !== null + ? { + name: "Preview branches", + description: "Maximum number of active preview branches", + limit: branchesLimit, + currentUsage: activeBranchCount, + source: "plan", + canExceed: limits?.branches?.canExceed, + isUpgradable: true, + } + : null, + logRetentionDays: + logRetentionDaysLimit !== null + ? { + name: "Log retention", + description: "Number of days logs are retained", + limit: logRetentionDaysLimit, + currentUsage: 0, // Not applicable - this is a duration, not a count + source: "plan", + } + : null, + realtimeConnections: + realtimeConnectionsLimit !== null + ? { + name: "Realtime connections", + description: "Maximum concurrent Realtime connections", + limit: realtimeConnectionsLimit, + currentUsage: 0, // Would need to query realtime service for this + source: "plan", + canExceed: limits?.realtimeConcurrentConnections?.canExceed, + isUpgradable: true, + } + : null, + devQueueSize: { + name: "Dev queue size", + description: "Maximum pending runs in development environments", + limit: organization.maximumDevQueueSize ?? null, + currentUsage: 0, // Would need to query Redis for this + source: organization.maximumDevQueueSize ? "override" : "default", + }, + deployedQueueSize: { + name: "Deployed queue size", + description: "Maximum pending runs in deployed environments", + limit: organization.maximumDeployedQueueSize ?? null, + currentUsage: 0, // Would need to query Redis for this + source: organization.maximumDeployedQueueSize ? "override" : "default", + }, + }, + batchConcurrency: { + limit: batchConcurrencyConfig.processingConcurrency, + source: batchConcurrencySource, + }, + features: { + hasStagingEnvironment: { + name: "Staging environment", + description: "Access to staging environment for testing before production", + enabled: hasStagingEnvironment, + }, + support: { + name: "Support level", + description: "Type of support available for your plan", + enabled: true, + value: supportLevel === "slack" ? "Slack" : "Community", + }, + includedUsage: { + name: "Included compute", + description: "Monthly included compute credits", + enabled: includedUsage !== null && includedUsage > 0, + value: includedUsage ?? 0, + }, + }, + planName: currentPlan?.v3Subscription?.plan?.title ?? null, + organizationId, + }; + } +} + +function resolveApiRateLimitConfig(apiRateLimiterConfig?: unknown): RateLimiterConfig { + const defaultConfig: RateLimitTokenBucketConfig = { + type: "tokenBucket", + refillRate: env.API_RATE_LIMIT_REFILL_RATE, + interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.API_RATE_LIMIT_MAX, + }; + + if (!apiRateLimiterConfig) { + return defaultConfig; + } + + const parsed = RateLimiterConfig.safeParse(apiRateLimiterConfig); + if (!parsed.success) { + return defaultConfig; + } + + return parsed.data; +} + +function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig { + const defaultConfig: RateLimitTokenBucketConfig = { + type: "tokenBucket", + refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE, + interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration, + maxTokens: env.BATCH_RATE_LIMIT_MAX, + }; + + if (!batchRateLimitConfig) { + return defaultConfig; + } + + const parsed = RateLimiterConfig.safeParse(batchRateLimitConfig); + if (!parsed.success) { + return defaultConfig; + } + + return parsed.data; +} + +function resolveBatchConcurrencyConfig(batchConcurrencyConfig?: unknown): { + processingConcurrency: number; +} { + const defaultConfig = { + processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT, + }; + + if (!batchConcurrencyConfig) { + return defaultConfig; + } + + if (typeof batchConcurrencyConfig === "object" && batchConcurrencyConfig !== null) { + const config = batchConcurrencyConfig as Record; + if (typeof config.processingConcurrency === "number") { + return { processingConcurrency: config.processingConcurrency }; + } + } + + return defaultConfig; +} + +/** + * Query Redis for the current remaining tokens for a rate limiter. + * The @upstash/ratelimit library stores token bucket state in Redis. + * Key format: ratelimit:{prefix}:{hashedIdentifier} + * + * For token bucket, the value is stored as: "tokens:lastRefillTime" + */ +async function getRateLimitRemainingTokens( + keyPrefix: string, + apiKey: string, + config: RateLimiterConfig +): Promise { + try { + // Hash the authorization header the same way the rate limiter does + const authorizationValue = `Bearer ${apiKey}`; + const hash = createHash("sha256"); + hash.update(authorizationValue); + const hashedKey = hash.digest("hex"); + + const redis = rateLimitRedis; + const redisKey = `ratelimit:${keyPrefix}:${hashedKey}`; + + // Get the stored value from Redis + const value = await redis.get(redisKey); + + if (!value) { + // No rate limit data yet - return max tokens (bucket is full) + if (config.type === "tokenBucket") { + return config.maxTokens; + } else if (config.type === "fixedWindow" || config.type === "slidingWindow") { + return config.tokens; + } + return null; + } + + // For token bucket, the @upstash/ratelimit library stores: "tokens:timestamp" + // Parse the value to get remaining tokens + if (typeof value === "string") { + const parts = value.split(":"); + if (parts.length >= 1) { + const tokens = parseInt(parts[0], 10); + if (!isNaN(tokens)) { + // For token bucket, we need to calculate current tokens based on refill + if (config.type === "tokenBucket" && parts.length >= 2) { + const lastRefillTime = parseInt(parts[1], 10); + if (!isNaN(lastRefillTime)) { + const now = Date.now(); + const elapsed = now - lastRefillTime; + const intervalMs = durationToMs(config.interval); + const tokensToAdd = Math.floor(elapsed / intervalMs) * config.refillRate; + const currentTokens = Math.min(tokens + tokensToAdd, config.maxTokens); + return Math.max(0, currentTokens); + } + } + return Math.max(0, tokens); + } + } + } + + return null; + } catch (error) { + logger.warn("Failed to get rate limit remaining tokens", { + keyPrefix, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Convert a duration string (e.g., "1s", "10s", "1m") to milliseconds + */ +function durationToMs(duration: Duration): number { + const match = duration.match(/^(\d+)(ms|s|m|h|d)$/); + if (!match) return 1000; // default to 1 second + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "ms": + return value; + case "s": + return value * 1000; + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return 1000; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx new file mode 100644 index 0000000000..22a2a64da9 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.limits/route.tsx @@ -0,0 +1,853 @@ +import { CheckIcon, BookOpenIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { IconCardsFilled, IconDiamondFilled, IconTallymark4 } from "@tabler/icons-react"; +import { tryCatch } from "@trigger.dev/core"; +import { Gauge } from "lucide-react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { Feedback } from "~/components/Feedback"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { EnvironmentSelector } from "~/components/navigation/EnvironmentSelector"; +import { Badge } from "~/components/primitives/Badge"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import * as Property from "~/components/primitives/PropertyTable"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { + LimitsPresenter, + type FeatureInfo, + type LimitsResult, + type QuotaInfo, + type RateLimitInfo, +} from "~/presenters/v3/LimitsPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { formatNumber } from "~/utils/numberFormatter"; +import { + concurrencyPath, + docsPath, + EnvironmentParamSchema, + organizationBillingPath, +} from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Limits | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response(undefined, { + status: 404, + statusText: "Project not found", + }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + const presenter = new LimitsPresenter(); + const [error, result] = await tryCatch( + presenter.call({ + userId, + projectId: project.id, + organizationId: project.organizationId, + environmentApiKey: environment.apiKey, + }) + ); + + if (error) { + throw new Response(error.message, { + status: 400, + }); + } + + // Match the queues page pattern: pass a poll interval from the loader + const autoReloadPollIntervalMs = 5000; + + return typedjson({ + ...result, + autoReloadPollIntervalMs, + }); +}; + +export default function Page() { + const data = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + // Auto-revalidate using the loader-provided interval and refresh on focus + useAutoRevalidate({ interval: data.autoReloadPollIntervalMs, onFocus: true }); + + return ( + + + + + + + + Plan + {data.planName ?? "No plan"} + + + Organization ID + {data.organizationId} + + + + + Limits docs + + + + +
+
+ {/* Current Plan Section */} + {data.planName && ( + + )} + + {/* Concurrency Section */} + + + {/* Rate Limits Section */} + + + {/* Quotas Section */} + + + {/* Features Section */} + +
+
+
+
+ ); +} + +function CurrentPlanSection({ + planName, + isOnTopPlan, + billingPath, +}: { + planName: string; + isOnTopPlan: boolean; + billingPath: string; +}) { + return ( +
+ + + Current plan + + + + + {planName} + + {isOnTopPlan ? ( + Request Enterprise} + defaultValue="help" + /> + ) : ( + + View plans + + )} + + + +
+
+ ); +} + +function ConcurrencySection({ concurrencyPath }: { concurrencyPath: string }) { + return ( +
+ + + Concurrency limits + + + + + + Concurrency + + + Manage concurrency + + + + +
+
+ ); +} + +function RateLimitsSection({ + rateLimits, + isOnTopPlan, + billingPath, + organization, + project, + environment, +}: { + rateLimits: LimitsResult["rateLimits"]; + isOnTopPlan: boolean; + billingPath: string; + organization: ReturnType; + project: ReturnType; + environment: ReturnType; +}) { + return ( +
+
+ + + Rate limits + + + +
+ + + + Rate limit + + + Type + +
+ + + Requests consume tokens from a bucket that refills over time. When empty, + requests are rate limited. + +
+
+ + + Allows a set number of requests per time window. The window resets at + fixed intervals. + +
+
+ + + Allows a set number of requests per rolling time window. The limit is + continuously evaluated. + +
+ + } + disableHoverableContent + /> +
+
+ Configuration + + + Available + + + + Upgrade +
+
+ + + + +
+
+ ); +} + +function RateLimitRow({ + info, + isOnTopPlan, + billingPath, +}: { + info: RateLimitInfo; + isOnTopPlan: boolean; + billingPath: string; +}) { + const maxTokens = info.config.type === "tokenBucket" ? info.config.maxTokens : info.config.tokens; + const percentage = + info.currentTokens !== null && maxTokens > 0 ? info.currentTokens / maxTokens : null; + + return ( + + + + {info.name} + + + + +
+ +
+
+ + + + + {info.currentTokens !== null ? ( +
+ + {formatNumber(info.currentTokens)} + + + of {formatNumber(maxTokens)} + +
+ ) : ( + + )} +
+ +
+ {info.name === "Batch rate limit" ? ( + isOnTopPlan ? ( + Contact us} + defaultValue="help" + /> + ) : ( + + View plans + + ) + ) : ( + Contact us} + defaultValue="help" + /> + )} +
+
+
+ ); +} + +function RateLimitTypeBadge({ + config, + type, +}: { + config?: RateLimitInfo["config"]; + type?: "tokenBucket" | "fixedWindow" | "slidingWindow"; +}) { + const rateLimitType = type ?? config?.type; + switch (rateLimitType) { + case "tokenBucket": + return ( + + Token bucket + + ); + case "fixedWindow": + return ( + + Fixed window + + ); + case "slidingWindow": + return ( + + Sliding window + + ); + default: + return null; + } +} + +function RateLimitConfigDisplay({ config }: { config: RateLimitInfo["config"] }) { + if (config.type === "tokenBucket") { + return ( +
+ + Max tokens:{" "} + {formatNumber(config.maxTokens)} + + + Refill:{" "} + + {formatNumber(config.refillRate)}/{config.interval} + + +
+ ); + } + + if (config.type === "fixedWindow" || config.type === "slidingWindow") { + return ( +
+ + Tokens:{" "} + {formatNumber(config.tokens)} + + + Window:{" "} + {config.window} + +
+ ); + } + + return ; +} + +function QuotasSection({ + quotas, + batchConcurrency, + isOnTopPlan, + billingPath, +}: { + quotas: LimitsResult["quotas"]; + batchConcurrency: LimitsResult["batchConcurrency"]; + isOnTopPlan: boolean; + billingPath: string; +}) { + // Collect all quotas that should be shown + const quotaRows: QuotaInfo[] = []; + + // Always show projects + quotaRows.push(quotas.projects); + + // Add plan-based quotas if they exist + if (quotas.teamMembers) quotaRows.push(quotas.teamMembers); + if (quotas.schedules) quotaRows.push(quotas.schedules); + if (quotas.alerts) quotaRows.push(quotas.alerts); + if (quotas.branches) quotaRows.push(quotas.branches); + if (quotas.realtimeConnections) quotaRows.push(quotas.realtimeConnections); + if (quotas.logRetentionDays) quotaRows.push(quotas.logRetentionDays); + + // Include batch processing concurrency as a quota row + quotaRows.push({ + name: "Batch processing concurrency", + description: "Controls how many batch items can be processed simultaneously.", + limit: batchConcurrency.limit, + currentUsage: 0, + source: batchConcurrency.source, + canExceed: true, // Allow contact us on top plan, view plans otherwise + isUpgradable: true, + }); + + // Add queue size quotas if set + if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize); + if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize); + + return ( +
+ + + Quotas + + + + + + Quota + Limit + Current + Source + Upgrade + + + + {quotaRows.map((quota) => ( + + ))} + +
+
+ ); +} + +function QuotaRow({ + quota, + isOnTopPlan, + billingPath, +}: { + quota: QuotaInfo; + isOnTopPlan: boolean; + billingPath: string; +}) { + // For log retention, we don't show current usage as it's a duration, not a count + const isRetentionQuota = quota.name === "Log retention"; + const percentage = + !isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null; + + // Special handling for Log retention + if (quota.name === "Log retention") { + const canUpgrade = !isOnTopPlan; + return ( + + + {quota.name} + + + + {quota.limit !== null ? `${formatNumber(quota.limit)} days` : "Unlimited"} + + + – + + + + + +
+ {canUpgrade ? ( + + View plans + + ) : ( + Contact us} + defaultValue="help" + /> + )} +
+
+
+ ); + } + + const renderUpgrade = () => { + // Projects always show Contact us (regardless of upgrade flags) + if (quota.name === "Projects") { + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + } + + if (!quota.isUpgradable) { + return null; + } + + // Not on top plan - show View plans + if (!isOnTopPlan) { + return ( +
+ + View plans + +
+ ); + } + + // On top plan - show Contact us if canExceed is true + if (quota.canExceed) { + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + } + + // On top plan but cannot exceed - no upgrade option + return null; + }; + + return ( + + + {quota.name} + + + + {quota.limit !== null + ? isRetentionQuota + ? `${formatNumber(quota.limit)} days` + : formatNumber(quota.limit) + : "Unlimited"} + + + {isRetentionQuota ? "–" : formatNumber(quota.currentUsage)} + + + + + {renderUpgrade()} + + ); +} + +function FeaturesSection({ + features, + isOnTopPlan, + billingPath, +}: { + features: LimitsResult["features"]; + isOnTopPlan: boolean; + billingPath: string; +}) { + // For staging environment: show View plans if not enabled (i.e., on Free plan) + const stagingUpgradeType = features.hasStagingEnvironment.enabled ? "none" : "view-plans"; + + return ( +
+ + + Plan features + + + + + Feature + Status + Upgrade + + + + + + + +
+
+ ); +} + +function FeatureRow({ + feature, + upgradeType, + billingPath, +}: { + feature: FeatureInfo; + upgradeType: "view-plans" | "contact-us" | "none"; + billingPath: string; +}) { + const displayValue = () => { + if (feature.name === "Included compute" && typeof feature.value === "number") { + if (!feature.enabled || feature.value === 0) { + return None; + } + return ( + ${formatNumber(feature.value / 100)} + ); + } + + if (feature.value !== undefined) { + return {feature.value}; + } + + return feature.enabled ? ( + + + Enabled + + ) : ( + Not available + ); + }; + + const renderUpgrade = () => { + switch (upgradeType) { + case "view-plans": + return ( +
+ + View plans + +
+ ); + case "contact-us": + return ( +
+ Contact us} + defaultValue="help" + /> +
+ ); + case "none": + return null; + } + }; + + return ( + + + {feature.name} + + + {displayValue()} + {renderUpgrade()} + + ); +} + +/** + * Returns the appropriate color class based on usage percentage. + * @param percentage - The usage percentage (0-1 scale) + * @param mode - "usage" means higher is worse (quotas), "remaining" means lower is worse (rate limits) + * @returns Tailwind color class + */ +function getUsageColorClass( + percentage: number | null, + mode: "usage" | "remaining" = "usage" +): string { + if (percentage === null) return "text-text-dimmed"; + + if (mode === "remaining") { + // For remaining tokens: 0 = bad (red), <=10% = warning (orange) + if (percentage <= 0) return "text-error"; + if (percentage <= 0.1) return "text-warning"; + return "text-text-bright"; + } else { + // For usage: 100% = bad (red), >=90% = warning (orange) + if (percentage >= 1) return "text-error"; + if (percentage >= 0.9) return "text-warning"; + return "text-text-bright"; + } +} + +function SourceBadge({ source }: { source: "default" | "plan" | "override" }) { + const variants: Record = { + default: { + label: "Default", + className: "bg-indigo-500/20 text-indigo-400", + }, + plan: { + label: "Plan", + className: "bg-purple-500/20 text-purple-400", + }, + override: { + label: "Override", + className: "bg-amber-500/20 text-amber-400", + }, + }; + + const variant = variants[source]; + + return ( + + {variant.label} + + ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index a2756f7e5b..639f2f7294 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -507,6 +507,14 @@ export function concurrencyPath( return `${v3EnvironmentPath(organization, project, environment)}/concurrency`; } +export function limitsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/limits`; +} + export function regionsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 175fb5b230..987e983862 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -111,7 +111,7 @@ "@sentry/remix": "9.46.0", "@slack/web-api": "7.9.1", "@socket.io/redis-adapter": "^8.3.0", - "@tabler/icons-react": "^2.39.0", + "@tabler/icons-react": "^3.36.1", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-virtual": "^3.0.4", "@team-plain/typescript-sdk": "^3.5.0", diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 9f4e4381b8..d7ee335694 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -160,6 +160,9 @@ const batches = colors.pink[500]; const schedules = colors.yellow[500]; const queues = colors.purple[500]; const deployments = colors.green[500]; +const concurrency = colors.amber[500]; +const limits = colors.purple[500]; +const regions = colors.green[500]; const logs = colors.blue[500]; const tests = colors.lime[500]; const apiKeys = colors.amber[500]; @@ -235,7 +238,10 @@ module.exports = { runs, batches, schedules, + concurrency, queues, + regions, + limits, deployments, logs, tests, diff --git a/internal-packages/tsql/src/query/printer.test.ts b/internal-packages/tsql/src/query/printer.test.ts index 5612b6e257..3ffb8eac27 100644 --- a/internal-packages/tsql/src/query/printer.test.ts +++ b/internal-packages/tsql/src/query/printer.test.ts @@ -2278,3 +2278,150 @@ describe("Field Mapping Value Transformation", () => { // But the column metadata should use the exposed name }); }); + +describe("Required Filters", () => { + /** + * Tests for tables with requiredFilters, which inject internal ClickHouse + * column conditions (like engine = 'V2') that aren't exposed in the schema. + */ + + const schemaWithRequiredFilters: TableSchema = { + name: "runs", + clickhouseName: "trigger_dev.task_runs_v2", + description: "Task runs table with required filters", + tenantColumns: { + organizationId: "organization_id", + projectId: "project_id", + environmentId: "environment_id", + }, + requiredFilters: [{ column: "engine", value: "V2" }], + columns: { + run_id: { + name: "run_id", + clickhouseName: "friendly_id", + ...column("String", { description: "Run ID", coreColumn: true }), + }, + status: { + name: "status", + ...column("String", { description: "Status" }), + }, + triggered_at: { + name: "triggered_at", + clickhouseName: "created_at", + ...column("DateTime64", { description: "When the run was triggered", coreColumn: true }), + }, + total_cost: { + name: "total_cost", + ...column("Float64", { description: "Total cost" }), + expression: "(cost_in_cents + base_cost_in_cents) / 100.0", + }, + }, + }; + + function createRequiredFiltersContext(): PrinterContext { + const schemaRegistry = createSchemaRegistry([schemaWithRequiredFilters]); + return createPrinterContext({ + organizationId: "org_test123", + projectId: "proj_test456", + environmentId: "env_test789", + schema: schemaRegistry, + }); + } + + function printQueryWithFilters(query: string): PrintResult { + const ctx = createRequiredFiltersContext(); + const ast = parseTSQLSelect(query); + const printer = new ClickHousePrinter(ctx); + return printer.print(ast); + } + + it("should NOT throw for internal engine column from requiredFilters", () => { + // This query should work even though 'engine' is not in the schema + // because it's automatically injected by requiredFilters + const { sql, params } = printQueryWithFilters("SELECT run_id, status FROM runs LIMIT 10"); + + // The engine filter should be in the WHERE clause + expect(sql).toContain("engine"); + // The V2 value is parameterized, so check the params + expect(Object.values(params)).toContain("V2"); + }); + + it("should allow TSQL column names that map to different ClickHouse names", () => { + // User writes 'triggered_at' but it maps to 'created_at' in ClickHouse + const { sql } = printQueryWithFilters(` + SELECT run_id, status, triggered_at + FROM runs + WHERE triggered_at > now() - INTERVAL 14 DAY + ORDER BY triggered_at DESC + LIMIT 100 + `); + + // The ClickHouse SQL should use 'created_at' instead of 'triggered_at' + expect(sql).toContain("created_at"); + // The result should still have the alias for the user-friendly name + expect(sql).toContain("AS triggered_at"); + }); + + it("should allow filtering by mapped column name", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id FROM runs WHERE triggered_at > '2024-01-01' LIMIT 10 + `); + + // Should use the ClickHouse column name in the WHERE clause + expect(sql).toContain("created_at"); + }); + + it("should allow ORDER BY on mapped column name", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id FROM runs ORDER BY triggered_at DESC LIMIT 10 + `); + + // ORDER BY should use the ClickHouse column name + expect(sql).toContain("ORDER BY"); + expect(sql).toContain("created_at"); + }); + + it("should handle virtual columns with expressions", () => { + const { sql } = printQueryWithFilters(` + SELECT run_id, total_cost FROM runs ORDER BY total_cost DESC LIMIT 10 + `); + + // Virtual column should be expanded to its expression with an alias + expect(sql).toContain("cost_in_cents"); + expect(sql).toContain("base_cost_in_cents"); + expect(sql).toContain("AS total_cost"); + }); + + it("should combine tenant guards with required filters", () => { + const { sql, params } = printQueryWithFilters("SELECT run_id FROM runs LIMIT 10"); + + // Should have all tenant columns AND the engine filter + expect(sql).toContain("organization_id"); + expect(sql).toContain("project_id"); + expect(sql).toContain("environment_id"); + expect(sql).toContain("engine"); + // The V2 value is parameterized, so check the params + expect(Object.values(params)).toContain("V2"); + }); + + it("should allow complex queries with mapped columns", () => { + // This query is similar to what a user might write + const { sql } = printQueryWithFilters(` + SELECT + run_id, + status, + total_cost, + triggered_at + FROM runs + WHERE triggered_at > now() - INTERVAL 14 DAY + ORDER BY total_cost DESC + LIMIT 100 + `); + + // All should work without errors + expect(sql).toContain("friendly_id"); // run_id maps to friendly_id + expect(sql).toContain("status"); + expect(sql).toContain("created_at"); // triggered_at maps to created_at + expect(sql).toContain("cost_in_cents"); // total_cost is a virtual column + }); +}); diff --git a/internal-packages/tsql/src/query/printer.ts b/internal-packages/tsql/src/query/printer.ts index 734b4dc87c..4f92a14073 100644 --- a/internal-packages/tsql/src/query/printer.ts +++ b/internal-packages/tsql/src/query/printer.ts @@ -1338,6 +1338,14 @@ export class ClickHousePrinter { // Register this table context for column name resolution this.tableContexts.set(effectiveAlias, tableSchema); + // Register required filter columns as allowed internal columns + // These are ClickHouse columns used for internal filtering (e.g., engine = 'V2') + if (tableSchema.requiredFilters) { + for (const filter of tableSchema.requiredFilters) { + this.allowedInternalColumns.add(filter.column); + } + } + // Add tenant isolation guard extraWhere = this.createTenantGuard(tableSchema, effectiveAlias); } else if ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fc310de96..76ba461892 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,8 +463,8 @@ importers: specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.4(bufferutil@4.0.9)) '@tabler/icons-react': - specifier: ^2.39.0 - version: 2.47.0(react@18.2.0) + specifier: ^3.36.1 + version: 3.36.1(react@18.2.0) '@tailwindcss/container-queries': specifier: ^0.1.1 version: 0.1.1(tailwindcss@3.4.1) @@ -10170,13 +10170,13 @@ packages: resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} - '@tabler/icons-react@2.47.0': - resolution: {integrity: sha512-iqly2FvCF/qUbgmvS8E40rVeYY7laltc5GUjRxQj59DuX0x/6CpKHTXt86YlI2whg4czvd/c8Ce8YR08uEku0g==} + '@tabler/icons-react@3.36.1': + resolution: {integrity: sha512-/8nOXeNeMoze9xY/QyEKG65wuvRhkT3q9aytaur6Gj8bYU2A98YVJyLc9MRmc5nVvpy+bRlrrwK/Ykr8WGyUWg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 + react: '>= 16' - '@tabler/icons@2.47.0': - resolution: {integrity: sha512-4w5evLh+7FUUiA1GucvGj2ReX2TvOjEr4ejXdwL/bsjoSkof6r1gQmzqI+VHrE2CpJpB3al7bCTulOkFa/RcyA==} + '@tabler/icons@3.36.1': + resolution: {integrity: sha512-f4Jg3Fof/Vru5ioix/UO4GX+sdDsF9wQo47FbtvG+utIYYVQ/QVAC0QYgcBbAjQGfbdOh2CCf0BgiFOF9Ixtjw==} '@tailwindcss/container-queries@0.1.1': resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} @@ -30258,13 +30258,12 @@ snapshots: dependencies: defer-to-connect: 1.1.3 - '@tabler/icons-react@2.47.0(react@18.2.0)': + '@tabler/icons-react@3.36.1(react@18.2.0)': dependencies: - '@tabler/icons': 2.47.0 - prop-types: 15.8.1 + '@tabler/icons': 3.36.1 react: 18.2.0 - '@tabler/icons@2.47.0': {} + '@tabler/icons@3.36.1': {} '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.1)': dependencies: