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);
|
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)', () => {
|
it('computes CPU line with cycle-native prices (no conversion)', () => {
|
||||||
const rates = [
|
const rates = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
case PricingResourceType.CPU_PER_CORE:
|
case PricingResourceType.CPU_PER_CORE:
|
||||||
return `CPU (${quantity.toFixed(2)} core)`;
|
return `CPU (${quantity.toFixed(2)} core)`;
|
||||||
case PricingResourceType.MEMORY_PER_GB:
|
case PricingResourceType.MEMORY_PER_GB:
|
||||||
return `Memory (${quantity.toFixed(2)} GB)`;
|
return `Memory (${quantity.toFixed(3)} GB billable)`;
|
||||||
case PricingResourceType.STORAGE_PER_GB:
|
case PricingResourceType.STORAGE_PER_GB:
|
||||||
return `Storage (${quantity} GB)`;
|
return `Storage (${quantity} GB)`;
|
||||||
default:
|
default:
|
||||||
@@ -616,12 +616,19 @@ export class PricingCatalogService implements OnModuleInit {
|
|||||||
return parseFloat(cpu) || 0;
|
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 {
|
parseMemoryToGb(memory: string): number {
|
||||||
if (!memory) return 0;
|
if (!memory) return 0;
|
||||||
if (memory.endsWith('Gi')) return parseFloat(memory);
|
const m = memory.trim();
|
||||||
if (memory.endsWith('Mi')) return parseFloat(memory) / 1024;
|
if (m.endsWith('Gi')) return parseFloat(m) || 0;
|
||||||
if (memory.endsWith('Ki')) return parseFloat(memory) / (1024 * 1024);
|
if (m.endsWith('Mi')) return (parseFloat(m) || 0) / 1000;
|
||||||
return parseFloat(memory) / (1024 * 1024 * 1024);
|
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) {
|
private async upsertRuntimeRate(runtime: AppRuntime, row: PricingRateRow) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
PricingResourceType,
|
PricingResourceType,
|
||||||
LifecycleSettings,
|
LifecycleSettings,
|
||||||
} from '@/types';
|
} 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> = {
|
const resourceLabels: Record<PricingResourceType, string> = {
|
||||||
base_fee: 'Base fee',
|
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">
|
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
||||||
<li>
|
<li>
|
||||||
<strong>Applications</strong> — user picks runtime resources in deploy; you set price per
|
<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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Optional services (Redis, RabbitMQ)</strong> — user enables the service, then sets
|
<strong>Optional services (Redis, RabbitMQ)</strong> — same unit matrix per service as
|
||||||
resources in a separate block; you set the same unit-price rows for that service.
|
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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Deploy defaults</strong> — optional prefill only; changing them does not change
|
<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="card space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Layers className="w-5 h-5 text-purple-600 shrink-0 mt-0.5" />
|
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 flex flex-wrap items-center justify-between gap-2">
|
<div>
|
||||||
<div>
|
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
Unit pricing per service (hourly / monthly / yearly)
|
||||||
Unit pricing + deploy wizard defaults per service
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{editing && activeOptionalEntry && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fillYearlyFromMonthly('optional')}
|
|
||||||
className="btn-secondary text-xs"
|
|
||||||
>
|
|
||||||
Fill yearly from monthly ×12
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -662,46 +653,84 @@ export default function AdminBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 && (
|
{activeOptionalEntry && (
|
||||||
<div className="space-y-6">
|
<PricingMatrixTable
|
||||||
<section className="space-y-3">
|
rows={optionalRateRows}
|
||||||
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
|
readOnly={!editing}
|
||||||
<Server className="w-4 h-4 text-gray-500" />
|
onChange={updateOptionalRate}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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';
|
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'];
|
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';
|
type OptionalServiceKey = 'redis' | 'rabbitmq';
|
||||||
|
|
||||||
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
|
||||||
@@ -305,11 +319,13 @@ export default function DeployPage() {
|
|||||||
replicas: form.replicas,
|
replicas: form.replicas,
|
||||||
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined,
|
dbStorageSize: form.databaseType !== 'none' ? `${parseInt(form.dbStorageSize || '1', 10) || 1}Gi` : undefined,
|
||||||
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`,
|
appStorageSize: `${parseInt(form.appStorageSize || '2', 10) || 2}Gi`,
|
||||||
enableRedis: form.enableRedis,
|
enableRedis: form.runtime === 'wordpress' ? false : form.enableRedis,
|
||||||
enableRabbitmq: form.enableRabbitmq,
|
enableRabbitmq: form.runtime === 'wordpress' ? false : form.enableRabbitmq,
|
||||||
enableElasticsearch: form.enableElasticsearch,
|
enableElasticsearch: form.runtime === 'wordpress' ? false : form.enableElasticsearch,
|
||||||
redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined,
|
redisResources:
|
||||||
rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
form.runtime !== 'wordpress' && form.enableRedis ? form.optionalServiceResources?.redis : undefined,
|
||||||
|
rabbitmqResources:
|
||||||
|
form.runtime !== 'wordpress' && form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
|
||||||
enableCustomDomain,
|
enableCustomDomain,
|
||||||
cycle: selectedCycle,
|
cycle: selectedCycle,
|
||||||
};
|
};
|
||||||
@@ -341,13 +357,13 @@ export default function DeployPage() {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
// First create the app
|
// First create the app
|
||||||
setDeployStage('creating');
|
setDeployStage('creating');
|
||||||
const payload = { ...form };
|
const payload = sanitizePayloadForWordPressRuntime({
|
||||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
...form,
|
||||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
...(form.databaseType !== 'none' && form.dbStorageSize
|
||||||
}
|
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
||||||
if (payload.appStorageSize) {
|
: {}),
|
||||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
|
||||||
}
|
});
|
||||||
const res = await api.post('/applications', payload);
|
const res = await api.post('/applications', payload);
|
||||||
const appId = res.data.id;
|
const appId = res.data.id;
|
||||||
|
|
||||||
@@ -419,13 +435,13 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
// Now create the app
|
// Now create the app
|
||||||
setDeployStage('creating');
|
setDeployStage('creating');
|
||||||
const payload = { ...form };
|
const payload = sanitizePayloadForWordPressRuntime({
|
||||||
if (payload.databaseType !== 'none' && payload.dbStorageSize) {
|
...form,
|
||||||
payload.dbStorageSize = `${parseInt(payload.dbStorageSize, 10) || 1}Gi`;
|
...(form.databaseType !== 'none' && form.dbStorageSize
|
||||||
}
|
? { dbStorageSize: `${parseInt(form.dbStorageSize, 10) || 1}Gi` }
|
||||||
if (payload.appStorageSize) {
|
: {}),
|
||||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
...(form.appStorageSize ? { appStorageSize: `${parseInt(form.appStorageSize, 10) || 2}Gi` } : {}),
|
||||||
}
|
});
|
||||||
const res = await api.post('/applications', payload);
|
const res = await api.post('/applications', payload);
|
||||||
const appId = res.data.id;
|
const appId = res.data.id;
|
||||||
|
|
||||||
@@ -478,13 +494,14 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: CreateApplicationDto) => {
|
mutationFn: async (data: CreateApplicationDto) => {
|
||||||
|
const sanitized = sanitizePayloadForWordPressRuntime(data);
|
||||||
setDeployStage('creating');
|
setDeployStage('creating');
|
||||||
const res = await api.post('/applications', data);
|
const res = await api.post('/applications', sanitized);
|
||||||
const appId = res.data.id;
|
const appId = res.data.id;
|
||||||
|
|
||||||
// Upload zip file if selected
|
// Upload zip file if selected
|
||||||
// Upload source (regular apps or WordPress migrate/public_html)
|
// 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) {
|
if (fileToUpload) {
|
||||||
setDeployStage('uploading-source');
|
setDeployStage('uploading-source');
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
@@ -499,7 +516,7 @@ export default function DeployPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload DB dump if provided and a database was requested
|
// 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');
|
setDeployStage('uploading-db');
|
||||||
setDbUploadProgress(0);
|
setDbUploadProgress(0);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -580,7 +597,7 @@ export default function DeployPage() {
|
|||||||
if (enableCustomDomain && customDomainInput.trim()) {
|
if (enableCustomDomain && customDomainInput.trim()) {
|
||||||
payload.customDomain = customDomainInput.trim();
|
payload.customDomain = customDomainInput.trim();
|
||||||
}
|
}
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(sanitizePayloadForWordPressRuntime(payload));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: File) => {
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
@@ -783,13 +800,29 @@ export default function DeployPage() {
|
|||||||
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
||||||
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
|
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 === '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 === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
|
||||||
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
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 === 'django') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||||||
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
|
else if (opt.value === 'php') { updates.port = 80; updates.phpVersion = '8.3'; }
|
||||||
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
|
else if (opt.value === 'dotnet') { updates.port = 5000; updates.runtimeVersion = '8.0'; }
|
||||||
setForm({ ...form, ...updates });
|
setForm({ ...form, ...updates });
|
||||||
|
if (opt.value === 'wordpress') {
|
||||||
|
setEnvFile(null);
|
||||||
|
setEnvKey('');
|
||||||
|
setEnvVal('');
|
||||||
|
if (envFileInputRef.current) envFileInputRef.current.value = '';
|
||||||
|
}
|
||||||
// Reset WordPress-specific state when switching types
|
// Reset WordPress-specific state when switching types
|
||||||
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
|
if (opt.value !== 'wordpress') { setWpMode('fresh'); setWpContentFile(null); }
|
||||||
}}
|
}}
|
||||||
@@ -1154,8 +1187,9 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Environment Variables */}
|
{form.runtime !== 'wordpress' && (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Environment Variables */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
|
<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">
|
<div className="flex flex-col sm:flex-row gap-2 mb-3">
|
||||||
<input
|
<input
|
||||||
@@ -1221,6 +1255,7 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1610,6 +1645,8 @@ export default function DeployPage() {
|
|||||||
|
|
||||||
{/* Optional Services Section */}
|
{/* Optional Services Section */}
|
||||||
<div className="bg-purple-50 rounded-xl p-5 border border-purple-200">
|
<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">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<Server className="w-5 h-5 text-purple-600" />
|
<Server className="w-5 h-5 text-purple-600" />
|
||||||
<h3 className="font-semibold text-gray-900">Optional Services</h3>
|
<h3 className="font-semibold text-gray-900">Optional Services</h3>
|
||||||
@@ -1844,8 +1881,11 @@ export default function DeployPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Custom Domain */}
|
{/* 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 ${
|
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
enableCustomDomain
|
enableCustomDomain
|
||||||
? dnsVerified
|
? dnsVerified
|
||||||
@@ -2245,10 +2285,70 @@ export default function DeployPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{(form.enableRedis || form.enableRabbitmq) && (
|
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq) && (
|
||||||
<div className="space-y-4 pt-2 border-t border-gray-200">
|
<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>
|
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">Optional services</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
@@ -2293,69 +2393,6 @@ export default function DeployPage() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2458,7 +2495,7 @@ export default function DeployPage() {
|
|||||||
<span className="text-sm text-gray-500">Port</span>
|
<span className="text-sm text-gray-500">Port</span>
|
||||||
<span className="text-sm font-medium">{form.port}</span>
|
<span className="text-sm font-medium">{form.port}</span>
|
||||||
</div>
|
</div>
|
||||||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
{form.runtime !== 'wordpress' && (form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">Optional Services</span>
|
<span className="text-sm text-gray-500">Optional Services</span>
|
||||||
<span className="text-sm font-medium text-right">
|
<span className="text-sm font-medium text-right">
|
||||||
@@ -2470,7 +2507,7 @@ export default function DeployPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.enableRedis && form.optionalServiceResources?.redis && (
|
{form.runtime !== 'wordpress' && form.enableRedis && form.optionalServiceResources?.redis && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">Redis resources</span>
|
<span className="text-sm text-gray-500">Redis resources</span>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -2481,7 +2518,7 @@ export default function DeployPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
|
{form.runtime !== 'wordpress' && form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">RabbitMQ resources</span>
|
<span className="text-sm text-gray-500">RabbitMQ resources</span>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -2492,7 +2529,7 @@ export default function DeployPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Object.keys(form.envVars || {}).length > 0 && (
|
{form.runtime !== 'wordpress' && Object.keys(form.envVars || {}).length > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-gray-500">Env Vars</span>
|
<span className="text-sm text-gray-500">Env Vars</span>
|
||||||
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user