Align admin optional-services billing UI with runtimes and bill RAM proportionally.

Split optional pricing vs deploy defaults and move custom domain to its own card; parse Mi as decimal GB so fractional memory scales linearly with per-GB rates. Deploy wizard hides env and optional services for WordPress and sanitizes create payloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-16 00:39:37 +03:30
parent 3a4fffcec4
commit 67b41ae311
4 changed files with 253 additions and 157 deletions
@@ -76,6 +76,29 @@ describe('PricingCatalogService', () => {
expect(service.parseCpuToCores('2')).toBe(2);
});
it('parses memory Mi to decimal GB for proportional billing (500Mi = 0.5 GB)', () => {
expect(service.parseMemoryToGb('500Mi')).toBe(0.5);
expect(service.parseMemoryToGb('1000Mi')).toBe(1);
expect(service.parseMemoryToGb('1Gi')).toBe(1);
});
it('bills memory proportional to decimal GB (500Mi at half the per-GB monthly rate)', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.MEMORY_PER_GB,
hourlyPrice: 0,
monthlyPrice: 10_000,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
const dto = { ...baseDto(), memoryLimit: '500Mi' };
const result = service.computeTotalsWithRates(dto, rates, emptyOptional());
expect(result.monthly).toBe(5000);
});
it('computes CPU line with cycle-native prices (no conversion)', () => {
const rates = [
{
+12 -5
View File
@@ -580,7 +580,7 @@ export class PricingCatalogService implements OnModuleInit {
case PricingResourceType.CPU_PER_CORE:
return `CPU (${quantity.toFixed(2)} core)`;
case PricingResourceType.MEMORY_PER_GB:
return `Memory (${quantity.toFixed(2)} GB)`;
return `Memory (${quantity.toFixed(3)} GB billable)`;
case PricingResourceType.STORAGE_PER_GB:
return `Storage (${quantity} GB)`;
default:
@@ -616,12 +616,19 @@ export class PricingCatalogService implements OnModuleInit {
return parseFloat(cpu) || 0;
}
/**
* Billable memory quantity for rates labeled "per GB".
* Uses decimal GB so fractional Mi scales linearly (e.g. 500Mi → 0.5 × per-GB price).
* Gi values are treated as GB-sized billing units (1Gi → 1 unit).
*/
parseMemoryToGb(memory: string): number {
if (!memory) return 0;
if (memory.endsWith('Gi')) return parseFloat(memory);
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
return parseFloat(memory) / (1024 * 1024 * 1024);
const m = memory.trim();
if (m.endsWith('Gi')) return parseFloat(m) || 0;
if (m.endsWith('Mi')) return (parseFloat(m) || 0) / 1000;
if (m.endsWith('Ki')) return (parseFloat(m) || 0) / 1_000_000;
const n = parseFloat(m);
return Number.isFinite(n) ? n / (1024 * 1024 * 1024) : 0;
}
private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) {
@@ -13,7 +13,7 @@ import type {
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server } from 'lucide-react';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'Base fee',
@@ -555,11 +555,13 @@ export default function AdminBillingPage() {
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
<li>
<strong>Applications</strong> user picks runtime resources in deploy; you set price per
core, GB, base fee, and database addon.
core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for
example half the per-GB rate at half a gigabyte of memory).
</li>
<li>
<strong>Optional services (Redis, RabbitMQ)</strong> user enables the service, then sets
resources in a separate block; you set the same unit-price rows for that service.
<strong>Optional services (Redis, RabbitMQ)</strong> same unit matrix per service as
runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in
proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate).
</li>
<li>
<strong>Deploy defaults</strong> optional prefill only; changing them does not change
@@ -625,23 +627,12 @@ export default function AdminBillingPage() {
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-purple-600 shrink-0 mt-0.5" />
<div className="flex-1 flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
<p className="text-sm text-gray-500 mt-0.5">
Unit pricing + deploy wizard defaults per service
</p>
</div>
{editing && activeOptionalEntry && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('optional')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
<p className="text-sm text-gray-500 mt-0.5">
Unit pricing per service (hourly / monthly / yearly)
</p>
</div>
</div>
@@ -662,46 +653,84 @@ export default function AdminBillingPage() {
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-gray-800">
{optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} unit prices
</h3>
{editing && activeOptionalEntry && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('optional')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
</div>
{activeOptionalEntry && (
<div className="space-y-6">
<section className="space-y-3">
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
<Server className="w-4 h-4 text-gray-500" />
Deploy wizard defaults
</h3>
<DeployDefaultsFields
service={activeOptionalService}
profile={activeOptionalEntry.profile}
readOnly={!editing}
onUpdate={updateOptionalProfile}
/>
</section>
<section className="space-y-3 pt-4 border-t border-gray-100">
<h3 className="text-sm font-semibold text-gray-800">Unit pricing</h3>
<p className="text-xs text-gray-500">
Billed from the resources the user selects for this service at deploy (CPU cores ×
rate, memory GB × rate, storage GB × rate, plus base fee if used).
</p>
<PricingMatrixTable
rows={optionalRateRows}
readOnly={!editing}
onChange={updateOptionalRate}
/>
</section>
</div>
)}
{customDomain && (
<div className="pt-4 border-t border-gray-100">
<CustomDomainPricing
customDomain={customDomain}
readOnly={!editing}
onChange={updateCustomDomain}
/>
</div>
<PricingMatrixTable
rows={optionalRateRows}
readOnly={!editing}
onChange={updateOptionalRate}
/>
)}
</div>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Optional services deploy defaults</h2>
<p className="text-sm text-gray-500 mt-0.5">
Prefill CPU, memory, and storage when a user enables each service in the deploy wizard
</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={`defaults-${tab.value}`}
type="button"
onClick={() => setActiveOptionalService(tab.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeOptionalService === tab.value
? 'bg-primary-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{activeOptionalEntry && (
<DeployDefaultsFields
service={activeOptionalService}
profile={activeOptionalEntry.profile}
readOnly={!editing}
onUpdate={updateOptionalProfile}
/>
)}
</div>
{customDomain && (
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Add-ons</h2>
<p className="text-sm text-gray-500 mt-0.5">Flat fees not tied to a runtime</p>
</div>
</div>
<CustomDomainPricing
customDomain={customDomain}
readOnly={!editing}
onChange={updateCustomDomain}
/>
</div>
)}
</>
)}
+131 -94
View File
@@ -20,6 +20,20 @@ import type {
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
/** WordPress uses the managed image stack — wizard hides env & optional services; strip if ever sent. */
function sanitizePayloadForWordPressRuntime(payload: CreateApplicationDto): CreateApplicationDto {
if (payload.runtime !== 'wordpress') return payload;
return {
...payload,
enableRedis: false,
enableRabbitmq: false,
enableElasticsearch: false,
optionalServiceResources: {},
envVars: {},
};
}
type OptionalServiceKey = 'redis' | 'rabbitmq';
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
@@ -305,11 +319,13 @@ export default function DeployPage() {
replicas: form.replicas,
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined,
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`,
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined,
rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis,
enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq,
enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch,
redisResources:
form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined,
rabbitmqResources:
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
enableCustomDomain,
cycle: selectedCycle,
};
@@ -341,13 +357,13 @@ export default function DeployPage() {
mutationFn: async () => {
// First create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const payload = sanitizePayloadForWordPressRuntime({
...form,
...(form.databaseType !== 'none' && form.dbStorageSize
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
: {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
});
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -419,13 +435,13 @@ export default function DeployPage() {
// Now create the app
setDeployStage('creating');
const payload = { ...form };
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
}
if (payload.appStorageSize) {
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
}
const payload = sanitizePayloadForWordPressRuntime({
...form,
...(form.databaseType !== 'none' && form.dbStorageSize
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
: {}),
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
});
const res = await api.post('/applications', payload);
const appId = res.data.id;
@@ -478,13 +494,14 @@ export default function DeployPage() {
const createMutation = useMutation({
mutationFn: async (data: CreateApplicationDto) => {
const sanitized = sanitizePayloadForWordPressRuntime(data);
setDeployStage('creating');
const res = await api.post('/applications', data);
const res = await api.post('/applications', sanitized);
const appId = res.data.id;
// Upload zip file if selected
// Upload source (regular apps or WordPress migrate/public_html)
const fileToUpload = data.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
const fileToUpload = sanitized.runtime === 'wordpress' ? ((wpMode === 'migrate' || wpMode === 'public_html') ? wpContentFile : null) : (sourceMethod === 'upload' ? zipFile : null);
if (fileToUpload) {
setDeployStage('uploading-source');
setUploadProgress(0);
@@ -499,7 +516,7 @@ export default function DeployPage() {
}
// Upload DB dump if provided and a database was requested
if (data.databaseType && data.databaseType !== 'none' && dbDumpFile) {
if (sanitized.databaseType && sanitized.databaseType !== 'none' && dbDumpFile) {
setDeployStage('uploading-db');
setDbUploadProgress(0);
const formData = new FormData();
@@ -580,7 +597,7 @@ export default function DeployPage() {
if (enableCustomDomain && customDomainInput.trim()) {
payload.customDomain = customDomainInput.trim();
}
createMutation.mutate(payload);
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
};
const handleFileSelect = useCallback((file: File) => {
@@ -783,13 +800,29 @@ export default function DeployPage() {
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
else if (opt.value === 'laravel') { updates.port = 80; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; }
else if (opt.value === 'wordpress') {
updates.port = 80;
updates.databaseType = 'mysql';
updates.runtimeVersion = '6.7';
updates.phpVersion = '8.3';
updates.enableRedis = false;
updates.enableRabbitmq = false;
updates.enableElasticsearch = false;
updates.optionalServiceResources = {};
updates.envVars = {};
}
else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
setForm({ ...form, ...updates });
if (opt.value === 'wordpress') {
setEnvFile(null);
setEnvKey('');
setEnvVal('');
if (envFileInputRef.current) envFileInputRef.current.value = '';
}
// Reset WordPress-specific state when switching types
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
}}
@@ -1154,8 +1187,9 @@ export default function DeployPage() {
</div>
)}
{/* Environment Variables */}
{form.runtime !== 'wordpress' && (
<div>
{/* Environment Variables */}
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
<div className="flex flex-col sm:flex-row gap-2 mb-3">
<input
@@ -1221,6 +1255,7 @@ export default function DeployPage() {
</div>
))}
</div>
)}
</div>
)}
@@ -1610,6 +1645,8 @@ export default function DeployPage() {
{/* Optional Services Section */}
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
{form.runtime !== 'wordpress' && (
<>
<div className="flex items-center gap-2 mb-4">
<Server className="w-5 h-5 text-purple-600" />
<h3 className="font-semibold text-gray-900">Optional Services</h3>
@@ -1844,8 +1881,11 @@ export default function DeployPage() {
</p>
)}
</>
)}
{/* Custom Domain */}
<div className="mt-4 space-y-3">
<div className={`space-y-3 ${form.runtime !== 'wordpress' ? 'mt-4' : ''}`}>
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
enableCustomDomain
? dnsVerified
@@ -2245,10 +2285,70 @@ export default function DeployPage() {
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{form.runtime === 'wordpress'
? 'Upload storage (wp-content)'
: form.runtime === 'laravel'
? 'Storage directory'
: 'Application data storage'}
</label>
<p className="text-xs text-gray-500 mb-3">
{form.runtime === 'wordpress'
? 'Space for uploads, plugins, themes, and other WordPress files.'
: form.runtime === 'laravel'
? 'Space for uploads, logs, cache, and other Laravel storage files.'
: 'Persistent space for application data, uploads, and files.'}
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={form.appStorageSize || '2'}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
setForm({ ...form, appStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{wpContentFile && form.runtime === 'wordpress' && (
<span className="text-xs text-gray-600">
Suggested from wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-2 text-xs text-gray-500">Minimum 1 GB Recommended: 2 GB or more</p>
</div>
</div>
{(form.enableRedis || form.enableRabbitmq) && (
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (
<div className="space-y-4 pt-2 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">Optional services</h3>
<p className="text-sm text-gray-500">
@@ -2293,69 +2393,6 @@ export default function DeployPage() {
</div>
)}
{/* App Storage Size (all app types) */}
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
<div className="flex items-center gap-2 mb-3">
<FolderUp className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-gray-900">
{form.runtime === 'wordpress' ? 'Upload Storage (wp-content)'
: form.runtime === 'laravel' ? 'Storage Directory'
: 'Application Data Storage'}
</h3>
</div>
<p className="text-sm text-gray-600 mb-4">
{form.runtime === 'wordpress'
? 'This space is used for uploads, plugins, themes, and other WordPress files.'
: form.runtime === 'laravel'
? 'This space is used for uploads, logs, cache, and other Laravel storage files.'
: 'This space is used for persistent application data, uploads, and files.'}
</p>
<div className="flex items-center gap-3">
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden bg-white">
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current > 1) setForm({ ...form, appStorageSize: String(current - 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) <= 1}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="number"
min={1}
max={100}
value={form.appStorageSize || '2'}
onChange={(e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 2));
setForm({ ...form, appStorageSize: String(val) });
}}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
onClick={() => {
const current = parseInt(form.appStorageSize || '2', 10);
if (current < 100) setForm({ ...form, appStorageSize: String(current + 1) });
}}
disabled={parseInt(form.appStorageSize || '2', 10) >= 100}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
{wpContentFile && form.runtime === 'wordpress' && (
<span className="text-xs text-green-600">
Suggested based on wp-content ({(wpContentFile.size / (1024 * 1024 * 1024)).toFixed(2)} GB)
</span>
)}
</div>
<p className="mt-2 text-xs text-gray-500">Minimum 1GB Recommended: 2GB or more</p>
</div>
</div>
)}
@@ -2458,7 +2495,7 @@ export default function DeployPage() {
<span className="text-sm text-gray-500">Port</span>
<span className="text-sm font-medium">{form.port}</span>
</div>
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Optional Services</span>
<span className="text-sm font-medium text-right">
@@ -2470,7 +2507,7 @@ export default function DeployPage() {
</span>
</div>
)}
{form.enableRedis && form.optionalServiceResources?.redis && (
{form.runtime !== 'wordpress' && form.enableRedis && form.optionalServiceResources?.redis && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Redis resources</span>
<span className="text-sm font-medium">
@@ -2481,7 +2518,7 @@ export default function DeployPage() {
</span>
</div>
)}
{form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
{form.runtime !== 'wordpress' && form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">RabbitMQ resources</span>
<span className="text-sm font-medium">
@@ -2492,7 +2529,7 @@ export default function DeployPage() {
</span>
</div>
)}
{Object.keys(form.envVars || {}).length > 0 && (
{form.runtime !== 'wordpress' && Object.keys(form.envVars || {}).length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Env Vars</span>
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>