diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa760814e7..d52a956b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,6 +257,11 @@ jobs: - name: Run builder Postgres repository tests run: pnpm --filter kilo-deploy-builder test:postgres + - name: Run container usage Postgres tests + # The meter suite connects to POSTGRES_URL directly. Avoid the Jest worker + # suffix that computeDatabaseUrl adds whenever NODE_ENV=test. + run: NODE_ENV=development pnpm drizzle migrate && pnpm --filter container-usage-meter test:postgres + build: needs: [changes, typecheck, lint, format-check, drizzle-check] if: needs.changes.outputs.kilocode_backend == 'true' diff --git a/apps/web/src/app/admin/cloud-billing-skus/CloudBillingSkuForm.tsx b/apps/web/src/app/admin/cloud-billing-skus/CloudBillingSkuForm.tsx new file mode 100644 index 0000000000..b298e793c3 --- /dev/null +++ b/apps/web/src/app/admin/cloud-billing-skus/CloudBillingSkuForm.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + createCloudBillingSkuInputSchema, + multiplyCloudBillingRate, + type CreateCloudBillingSkuInput, +} from '@/lib/cloud-billing-sku'; + +type Props = { + pending: boolean; + serverErrors?: Partial>; + onSubmit: (values: CreateCloudBillingSkuInput) => void; +}; + +type RawForm = { + id: string; + name: string; + description: string; + rate: string; + exampleMinutes: string; +}; + +function preview(rate: string, seconds: number): string { + const parsed = createCloudBillingSkuInputSchema.shape.rate_cents_per_unit.safeParse(rate); + return parsed.success ? `${multiplyCloudBillingRate(parsed.data, seconds)} cents` : '—'; +} + +export default function CloudBillingSkuForm({ pending, serverErrors, onSubmit }: Props) { + const [raw, setRaw] = useState({ + id: '', + name: '', + description: '', + rate: '', + exampleMinutes: '15', + }); + const [errors, setErrors] = useState>>( + {} + ); + const idRef = useRef(null); + const nameRef = useRef(null); + const descriptionRef = useRef(null); + const rateRef = useRef(null); + + const exampleMinutes = /^\d+$/.test(raw.exampleMinutes) ? Number(raw.exampleMinutes) : 0; + + useEffect(() => { + if (!serverErrors || Object.keys(serverErrors).length === 0) return; + setErrors(current => ({ ...current, ...serverErrors })); + if (serverErrors.id) idRef.current?.focus(); + }, [serverErrors]); + + const clearError = (key: keyof CreateCloudBillingSkuInput) => { + setErrors(current => { + if (!current[key]) return current; + const next = { ...current }; + delete next[key]; + return next; + }); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const parsed = createCloudBillingSkuInputSchema.safeParse({ + id: raw.id.trim(), + name: raw.name.trim(), + description: raw.description.trim() || null, + unit: 'second', + rate_cents_per_unit: raw.rate.trim(), + }); + if (!parsed.success) { + const next: Partial> = {}; + for (const issue of parsed.error.issues) { + const key = issue.path[0] as keyof CreateCloudBillingSkuInput | undefined; + if (key && !next[key]) next[key] = issue.message; + } + setErrors(next); + const firstKey = parsed.error.issues[0]?.path[0]; + if (firstKey === 'id') idRef.current?.focus(); + if (firstKey === 'name') nameRef.current?.focus(); + if (firstKey === 'description') descriptionRef.current?.focus(); + if (firstKey === 'rate_cents_per_unit') rateRef.current?.focus(); + return; + } + setErrors({}); + onSubmit(parsed.data); + }; + + return ( +
+
+
+ + { + clearError('id'); + setRaw(current => ({ ...current, id: event.target.value })); + }} + /> +

+ Producer-facing constant. Use lowercase letters, numbers, and hyphens. +

+ {errors.id && ( + + )} +
+ +
+ + { + clearError('name'); + setRaw(current => ({ ...current, name: event.target.value })); + }} + /> + {errors.name && ( + + )} +
+ +
+ +