Add optional service pricing matrix and fix admin catalog save.

Users pick per-service CPU/memory/storage at deploy; admins manage unit rates and deploy defaults. PATCH sends only fields accepted by the pricing-catalog DTO.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 19:16:20 +03:30
parent bb27c90ae4
commit 055e7a7c8d
21 changed files with 1746 additions and 257 deletions
+417 -54
View File
@@ -7,11 +7,13 @@ import { toast } from 'react-toastify';
import type {
PricingCatalog,
PricingRateRow,
OptionalServiceProfileRow,
CustomDomainCatalogRow,
BillingCycle,
PricingResourceType,
LifecycleSettings,
} from '@/types';
import { DollarSign, Edit2, Shield, Clock, Layers } from 'lucide-react';
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server } from 'lucide-react';
const resourceLabels: Record<PricingResourceType, string> = {
base_fee: 'Base fee',
@@ -19,23 +21,62 @@ const resourceLabels: Record<PricingResourceType, string> = {
memory_per_gb: 'Memory (per GB)',
storage_per_gb: 'Storage (per GB)',
database_addon: 'Database addon',
redis_addon: 'Redis (flat addon)',
rabbitmq_addon: 'RabbitMQ (flat addon)',
elasticsearch_addon: 'Elasticsearch (flat addon)',
redis_addon: 'Redis',
rabbitmq_addon: 'RabbitMQ',
elasticsearch_addon: 'Elasticsearch',
custom_domain_addon: 'Custom domain + SSL',
};
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
function toPricingCatalogPatch(catalog: PricingCatalog) {
const optionalServices: Record<
string,
{ service: string; profile: OptionalServiceProfileRow; rates: PricingRateRow[] }
> = {};
for (const [key, entry] of Object.entries(catalog.optionalServices)) {
optionalServices[key] = {
service: entry.service ?? key,
profile: entry.profile,
rates: entry.rates,
};
}
return {
runtimes: catalog.runtimes,
optionalServices,
customDomain: catalog.customDomain,
};
}
function formatApiError(err: unknown, fallback: string): string {
if (!err || typeof err !== 'object' || !('response' in err)) return fallback;
const message = (err as { response?: { data?: { message?: string | string[] } } }).response
?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
return fallback;
}
function cloneCatalog(catalog: PricingCatalog): PricingCatalog {
const runtimes: PricingCatalog['runtimes'] = {};
for (const key of Object.keys(catalog.runtimes)) {
runtimes[key] = catalog.runtimes[key].map((r) => ({ ...r }));
}
const optionalServices: PricingCatalog['optionalServices'] = {};
for (const key of Object.keys(catalog.optionalServices)) {
const entry = catalog.optionalServices[key];
optionalServices[key] = {
...entry,
profile: { ...entry.profile },
rates: entry.rates.map((r) => ({ ...r })),
};
}
return {
...catalog,
runtimes,
addons: catalog.addons.map((a) => ({ ...a })),
optionalServices,
customDomain: { ...catalog.customDomain },
};
}
@@ -107,9 +148,200 @@ function PricingMatrixTable({
);
}
function DeployDefaultsFields({
service,
profile,
readOnly,
onUpdate,
}: {
service: string;
profile: OptionalServiceProfileRow;
readOnly: boolean;
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
}) {
const isLogging = service === 'elasticsearch';
if (isLogging) {
return (
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
<p className="text-xs text-gray-600">
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="50m"
value={profile.logShipperCpuLimit ?? ''}
onChange={(e) => onUpdate({ logShipperCpuLimit: e.target.value })}
/>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
) : (
<input
className="input-field w-full text-sm mt-0.5"
placeholder="64Mi"
value={profile.logShipperMemoryLimit ?? ''}
onChange={(e) => onUpdate({ logShipperMemoryLimit: e.target.value })}
/>
)}
</div>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
<p className="text-xs text-gray-600">
Shown when a user enables this service in deploy. They can change CPU, memory, and storage in
Resources & Configuration; actual billing uses their choices × unit prices below.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-gray-600">CPU request</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.cpuRequest || '50m'}
onChange={(e) => onUpdate({ cpuRequest: e.target.value })}
>
<option value="50m">50m</option>
<option value="100m">100m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">CPU limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.cpuLimit}
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
>
<option value="200m">200m</option>
<option value="250m">250m</option>
<option value="500m">500m</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory request</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.memoryRequest || '64Mi'}
onChange={(e) => onUpdate({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
)}
</div>
<div>
<label className="text-xs font-medium text-gray-600">Memory limit</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
) : (
<select
className="input-field w-full text-sm mt-0.5"
value={profile.memoryLimit}
onChange={(e) => onUpdate({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
)}
</div>
<div className="sm:col-span-2">
<label className="text-xs font-medium text-gray-600">Storage (Gi)</label>
{readOnly ? (
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
) : (
<input
type="number"
min={0}
step={0.1}
className="input-field w-full max-w-[140px] text-sm mt-0.5"
value={profile.storageGi}
onChange={(e) =>
onUpdate({ storageGi: e.target.value === '' ? 0 : Number(e.target.value) })
}
/>
)}
</div>
</div>
</div>
);
}
function CustomDomainPricing({
customDomain,
readOnly,
onChange,
}: {
customDomain: CustomDomainCatalogRow;
readOnly: boolean;
onChange: (cycle: BillingCycle, value: number) => void;
}) {
return (
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
<div className="grid grid-cols-3 gap-2">
{cycles.map((cycle) => {
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
return (
<div key={cycle}>
<label className="text-xs text-gray-500 capitalize">{cycle} (T)</label>
{readOnly ? (
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
) : (
<input
type="number"
min={0}
className="input-field w-full text-sm mt-0.5"
value={customDomain[field]}
onChange={(e) =>
onChange(cycle, e.target.value === '' ? 0 : Number(e.target.value))
}
/>
)}
</div>
);
})}
</div>
</div>
);
}
export default function AdminBillingPage() {
const queryClient = useQueryClient();
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<PricingCatalog | null>(null);
@@ -126,22 +358,27 @@ export default function AdminBillingPage() {
}
}, [catalog, activeRuntime]);
useEffect(() => {
const options = catalog?.optionalServiceOptions ?? [];
if (options.length === 0) return;
if (!options.some((o) => o.value === activeOptionalService)) {
setActiveOptionalService(options[0].value);
}
}, [catalog, activeOptionalService]);
const saveMutation = useMutation({
mutationFn: (body: PricingCatalog) => api.patch('/billing/pricing-catalog', body),
mutationFn: (catalog: PricingCatalog) =>
api.patch('/billing/pricing-catalog', toPricingCatalogPatch(catalog)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
toast.success('Pricing catalog saved');
toast.success('Billing plans saved');
setEditing(false);
setDraft(null);
},
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save pricing');
toast.error(formatApiError(err, 'Failed to save billing plans'));
},
});
@@ -176,7 +413,23 @@ export default function AdminBillingPage() {
});
};
const updateAddonPrice = (
const updateOptionalProfile = (patch: Partial<OptionalServiceProfileRow>) => {
if (!draft) return;
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
profile: { ...entry.profile, ...patch },
},
},
});
};
const updateOptionalRate = (
resourceType: PricingResourceType,
cycle: BillingCycle,
value: number,
@@ -184,15 +437,33 @@ export default function AdminBillingPage() {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
addons: draft.addons.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) =>
row.resourceType === resourceType ? { ...row, [field]: value } : row,
),
},
},
});
};
const fillYearlyFromMonthly = (scope: 'runtime' | 'addons') => {
const updateCustomDomain = (cycle: BillingCycle, value: number) => {
if (!draft) return;
const field =
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
setDraft({
...draft,
customDomain: { ...draft.customDomain, [field]: value },
});
};
const fillYearlyFromMonthly = (scope: 'runtime' | 'optional') => {
if (!draft) return;
if (scope === 'runtime') {
setDraft({
@@ -206,12 +477,20 @@ export default function AdminBillingPage() {
},
});
} else {
const entry = draft.optionalServices[activeOptionalService];
if (!entry) return;
setDraft({
...draft,
addons: draft.addons.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
optionalServices: {
...draft.optionalServices,
[activeOptionalService]: {
...entry,
rates: entry.rates.map((row) => ({
...row,
yearlyPrice: Math.round(Number(row.monthlyPrice) * 12),
})),
},
},
});
}
};
@@ -222,17 +501,21 @@ export default function AdminBillingPage() {
};
const runtimeRows = display?.runtimes[activeRuntime] ?? [];
const addonRows = display?.addons ?? [];
const optionalServiceTabs = display?.optionalServiceOptions ?? [];
const activeOptionalEntry = display?.optionalServices[activeOptionalService];
const optionalRateRows = activeOptionalEntry?.rates ?? [];
const customDomain = display?.customDomain;
return (
<div className="max-w-5xl mx-auto space-y-6 animate-fade-in">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="page-title flex items-center gap-2">
<DollarSign className="w-6 h-6" /> Billing & Pricing
<DollarSign className="w-6 h-6" /> Billing Plans
</h1>
<p className="page-subtitle">
Usage-based prices per application type (all runtimes from the platform). Optional services also bill CPU, RAM, and disk at the same unit rates as the app, plus any flat addon fee below.
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
their usage × these rates.
</p>
</div>
{!editing ? (
@@ -241,7 +524,7 @@ export default function AdminBillingPage() {
disabled={!catalog}
className="btn-primary flex items-center gap-2 shrink-0"
>
<Edit2 className="w-4 h-4" /> Edit pricing
<Edit2 className="w-4 h-4" /> Edit plans
</button>
) : (
<div className="flex gap-2 shrink-0">
@@ -265,6 +548,27 @@ export default function AdminBillingPage() {
)}
</div>
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
<div className="space-y-1">
<p className="font-medium">How billing works</p>
<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.
</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.
</li>
<li>
<strong>Deploy defaults</strong> optional prefill only; changing them does not change
what existing apps pay unless the user chose those values at deploy.
</li>
</ul>
</div>
</div>
{isLoading ? (
<div className="text-center py-12 text-gray-400">Loading...</div>
) : !display ? (
@@ -272,6 +576,14 @@ export default function AdminBillingPage() {
) : (
<>
<div className="card space-y-4">
<div className="flex items-start gap-3">
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
<div>
<h2 className="text-lg font-semibold text-gray-900">Application runtimes</h2>
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
</div>
</div>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{runtimeTabs.map((tab) => (
<button
@@ -290,9 +602,9 @@ export default function AdminBillingPage() {
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900">
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} resources
</h2>
<h3 className="text-sm font-semibold text-gray-800">
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} unit prices
</h3>
{editing && (
<button
type="button"
@@ -312,28 +624,83 @@ export default function AdminBillingPage() {
</div>
<div className="card space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Layers className="w-5 h-5" /> Platform add-ons
</h2>
{editing && (
<button
type="button"
onClick={() => fillYearlyFromMonthly('addons')}
className="btn-secondary text-xs"
>
Fill yearly from monthly ×12
</button>
)}
<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>
)}
</div>
</div>
<p className="text-sm text-gray-500">
Flat addon fees (optional). Deploy cost also includes each service&apos;s CPU, RAM, and disk at the app runtime unit rates above.
</p>
<PricingMatrixTable
rows={addonRows}
readOnly={!editing}
onChange={updateAddonPrice}
/>
<div className="flex flex-wrap gap-2 border-b border-gray-100 pb-3">
{optionalServiceTabs.map((tab) => (
<button
key={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 && (
<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>
)}
</div>
</>
)}
@@ -363,11 +730,7 @@ function LifecycleSettingsSection() {
setEditing(false);
},
onError: (err: unknown) => {
const message =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
toast.error(message || 'Failed to save');
toast.error(formatApiError(err, 'Failed to save'));
},
});
+282 -6
View File
@@ -7,10 +7,177 @@ import api from '@/lib/api';
import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store';
import { toast } from 'react-toastify';
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, DeployCostPreview, BillingCycle } from '@/types';
import type {
CreateApplicationDto,
ClusterPublic,
ClusterPoolPublic,
DeployCostPreview,
BillingCycle,
PricingCatalog,
OptionalServiceResourceConfig,
OptionalServiceResourcesMap,
} from '@/types';
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'];
type OptionalServiceKey = 'redis' | 'rabbitmq';
const FALLBACK_OPTIONAL_RESOURCES: Record<OptionalServiceKey, OptionalServiceResourceConfig> = {
redis: {
cpuRequest: '50m',
cpuLimit: '200m',
memoryRequest: '64Mi',
memoryLimit: '256Mi',
storageGi: 1,
},
rabbitmq: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
storageGi: 2,
},
};
function optionalDefaultsFromCatalog(
catalog: PricingCatalog | undefined,
service: OptionalServiceKey,
): OptionalServiceResourceConfig {
const profile = catalog?.optionalServices?.[service]?.profile;
const fallback = FALLBACK_OPTIONAL_RESOURCES[service];
if (!profile) return { ...fallback };
return {
cpuRequest: profile.cpuRequest || fallback.cpuRequest,
cpuLimit: profile.cpuLimit || fallback.cpuLimit,
memoryRequest: profile.memoryRequest || fallback.memoryRequest,
memoryLimit: profile.memoryLimit || fallback.memoryLimit,
storageGi: profile.storageGi ?? fallback.storageGi,
};
}
function WorkloadResourceFields({
title,
accentClass,
borderClass,
bgClass,
config,
readOnly,
onChange,
}: {
title: string;
accentClass: string;
borderClass: string;
bgClass: string;
config: OptionalServiceResourceConfig;
readOnly?: boolean;
onChange: (patch: Partial<OptionalServiceResourceConfig>) => void;
}) {
const storageStr = String(config.storageGi);
const setStorage = (gb: number) => onChange({ storageGi: Math.max(0, gb) });
return (
<div className={`rounded-xl p-5 border-2 ${borderClass} ${bgClass}`}>
<h3 className={`font-semibold text-gray-900 mb-4 flex items-center gap-2`}>
<Server className={`w-5 h-5 ${accentClass}`} />
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
<select
className="input-field"
disabled={readOnly}
value={config.cpuRequest || '50m'}
onChange={(e) => onChange({ cpuRequest: e.target.value })}
>
<option value="50m">50m (0.05 core)</option>
<option value="100m">100m (0.1 core)</option>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Limit</label>
<select
className="input-field"
disabled={readOnly}
value={config.cpuLimit}
onChange={(e) => onChange({ cpuLimit: e.target.value })}
>
<option value="200m">200m (0.2 core)</option>
<option value="250m">250m (0.25 core)</option>
<option value="500m">500m (0.5 core)</option>
<option value="1">1 core</option>
<option value="2">2 cores</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Request</label>
<select
className="input-field"
disabled={readOnly}
value={config.memoryRequest || '64Mi'}
onChange={(e) => onChange({ memoryRequest: e.target.value })}
>
<option value="64Mi">64 Mi</option>
<option value="128Mi">128 Mi</option>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Memory Limit</label>
<select
className="input-field"
disabled={readOnly}
value={config.memoryLimit}
onChange={(e) => onChange({ memoryLimit: e.target.value })}
>
<option value="256Mi">256 Mi</option>
<option value="512Mi">512 Mi</option>
<option value="1Gi">1 Gi</option>
<option value="2Gi">2 Gi</option>
</select>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Storage</label>
<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"
disabled={readOnly || config.storageGi <= 0}
onClick={() => setStorage(config.storageGi - 1)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30"
>
</button>
<input
type="number"
min={0}
max={100}
disabled={readOnly}
value={storageStr}
onChange={(e) => setStorage(parseInt(e.target.value, 10) || 0)}
className="w-16 text-center py-2 border-x border-gray-300 text-sm font-semibold focus:outline-none"
/>
<button
type="button"
disabled={readOnly || config.storageGi >= 100}
onClick={() => setStorage(config.storageGi + 1)}
className="px-3 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold disabled:opacity-30"
>
+
</button>
</div>
<span className="text-sm font-medium text-gray-700">GB</span>
</div>
</div>
</div>
);
}
type DeployStage = 'idle' | 'creating' | 'uploading-source' | 'uploading-db' | 'paying' | 'deploying' | 'done' | 'error';
@@ -56,6 +223,7 @@ export default function DeployPage() {
rabbitmqVersion: '3.13',
enableElasticsearch: false,
elasticsearchVersion: '8.12',
optionalServiceResources: {},
});
const [envKey, setEnvKey] = useState('');
const [envVal, setEnvVal] = useState('');
@@ -92,6 +260,12 @@ export default function DeployPage() {
enabled: isAdmin,
});
const { data: pricingCatalog } = useQuery<PricingCatalog>({
queryKey: ['pricing-catalog'],
queryFn: () => api.get('/billing/pricing-catalog').then((r) => r.data),
enabled: step >= 1,
});
// ── Custom Domain ──────────────────────────────
const [enableCustomDomain, setEnableCustomDomain] = useState(false);
const [customDomainInput, setCustomDomainInput] = useState('');
@@ -134,6 +308,8 @@ export default function DeployPage() {
enableRedis: form.enableRedis,
enableRabbitmq: form.enableRabbitmq,
enableElasticsearch: form.enableElasticsearch,
redisResources: form.enableRedis ? form.optionalServiceResources?.redis : undefined,
rabbitmqResources: form.enableRabbitmq ? form.optionalServiceResources?.rabbitmq : undefined,
enableCustomDomain,
cycle: selectedCycle,
};
@@ -142,14 +318,14 @@ export default function DeployPage() {
const { data: costData, isLoading: costLoading } = useQuery<DeployCostPreview>({
queryKey: ['deploy-cost', deployCostPayload],
queryFn: () => api.post('/billing/calculate-deploy', deployCostPayload).then((r) => r.data),
enabled: step === 3,
enabled: step >= 2,
});
// Wallet balance for the review step payment
const { data: walletData } = useQuery<{ balance: number }>({
queryKey: ['wallet-balance'],
queryFn: () => api.get('/billing/wallet').then((r) => r.data),
enabled: step === 3,
enabled: step >= 2,
});
const payAmount = costData?.amountDue ?? 0;
@@ -1380,7 +1556,21 @@ export default function DeployPage() {
}`}>
<button
type="button"
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
onClick={() => {
const next = !form.enableRedis;
setForm({
...form,
enableRedis: next,
optionalServiceResources: next
? {
...form.optionalServiceResources,
redis:
form.optionalServiceResources?.redis ??
optionalDefaultsFromCatalog(pricingCatalog, 'redis'),
}
: form.optionalServiceResources,
});
}}
className="w-full text-left"
>
<div className="flex items-center gap-3">
@@ -1424,7 +1614,21 @@ export default function DeployPage() {
}`}>
<button
type="button"
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
onClick={() => {
const next = !form.enableRabbitmq;
setForm({
...form,
enableRabbitmq: next,
optionalServiceResources: next
? {
...form.optionalServiceResources,
rabbitmq:
form.optionalServiceResources?.rabbitmq ??
optionalDefaultsFromCatalog(pricingCatalog, 'rabbitmq'),
}
: form.optionalServiceResources,
});
}}
className="w-full text-left"
>
<div className="flex items-center gap-3">
@@ -1972,6 +2176,8 @@ export default function DeployPage() {
</div>
)}
<div className="rounded-xl border border-primary-100 bg-primary-50/40 p-4 space-y-4">
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">Application workload</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">CPU Request</label>
@@ -2033,6 +2239,54 @@ export default function DeployPage() {
</div>
</div>
</div>
{(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">
Configure resources for each enabled service separately from your application.
</p>
{form.enableRedis && form.optionalServiceResources?.redis && (
<WorkloadResourceFields
title="Redis"
accentClass="text-red-500"
borderClass="border-red-200"
bgClass="bg-red-50/50"
config={form.optionalServiceResources.redis}
onChange={(patch) =>
setForm({
...form,
optionalServiceResources: {
...form.optionalServiceResources,
redis: { ...form.optionalServiceResources!.redis!, ...patch },
},
})
}
/>
)}
{form.enableRabbitmq && form.optionalServiceResources?.rabbitmq && (
<WorkloadResourceFields
title="RabbitMQ"
accentClass="text-orange-500"
borderClass="border-orange-200"
bgClass="bg-orange-50/50"
config={form.optionalServiceResources.rabbitmq}
onChange={(patch) =>
setForm({
...form,
optionalServiceResources: {
...form.optionalServiceResources,
rabbitmq: { ...form.optionalServiceResources!.rabbitmq!, ...patch },
},
})
}
/>
)}
</div>
)}
{/* Environment Variables */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Environment Variables</label>
@@ -2205,7 +2459,7 @@ export default function DeployPage() {
{(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">
<span className="text-sm font-medium text-right">
{[
form.enableRedis && 'Redis',
form.enableRabbitmq && 'RabbitMQ',
@@ -2214,6 +2468,28 @@ export default function DeployPage() {
</span>
</div>
)}
{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">
{form.optionalServiceResources.redis.cpuRequest} / {form.optionalServiceResources.redis.cpuLimit}
{' · '}
{form.optionalServiceResources.redis.memoryRequest} / {form.optionalServiceResources.redis.memoryLimit}
{' · '}{form.optionalServiceResources.redis.storageGi} GB
</span>
</div>
)}
{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">
{form.optionalServiceResources.rabbitmq.cpuRequest} / {form.optionalServiceResources.rabbitmq.cpuLimit}
{' · '}
{form.optionalServiceResources.rabbitmq.memoryRequest} / {form.optionalServiceResources.rabbitmq.memoryLimit}
{' · '}{form.optionalServiceResources.rabbitmq.storageGi} GB
</span>
</div>
)}
{Object.keys(form.envVars || {}).length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-gray-500">Env Vars</span>
+46 -2
View File
@@ -217,6 +217,20 @@ export interface CreateApplicationDto {
enableElasticsearch?: boolean;
elasticsearchVersion?: string;
logPaths?: string[];
optionalServiceResources?: OptionalServiceResourcesMap;
}
export interface OptionalServiceResourceConfig {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
}
export interface OptionalServiceResourcesMap {
redis?: OptionalServiceResourceConfig;
rabbitmq?: OptionalServiceResourceConfig;
}
export interface ClusterPublic {
@@ -383,6 +397,36 @@ export interface PricingRateRow {
isActive?: boolean;
}
export interface CyclePrices {
hourly: number;
monthly: number;
yearly: number;
}
export interface OptionalServiceProfileRow {
cpuRequest?: string;
memoryRequest?: string;
cpuLimit: string;
memoryLimit: string;
storageGi: number;
logShipperCpuLimit?: string;
logShipperMemoryLimit?: string;
}
export interface OptionalServiceCatalogEntry {
service: string;
label: string;
profile: OptionalServiceProfileRow;
rates: PricingRateRow[];
}
export interface CustomDomainCatalogRow {
hourlyPrice: number;
monthlyPrice: number;
yearlyPrice: number;
isActive?: boolean;
}
export interface CatalogRuntimeOption {
value: string;
label: string;
@@ -391,12 +435,12 @@ export interface CatalogRuntimeOption {
export interface CatalogOptionalServiceOption {
value: string;
label: string;
resourceType: PricingResourceType;
}
export interface PricingCatalog {
runtimes: Record<string, PricingRateRow[]>;
addons: PricingRateRow[];
optionalServices: Record<string, OptionalServiceCatalogEntry>;
customDomain: CustomDomainCatalogRow;
runtimeOptions: CatalogRuntimeOption[];
optionalServiceOptions: CatalogOptionalServiceOption[];
}