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:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user