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:
@@ -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'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'));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user