feat: add custom domain support with SSL, DNS verification, and billing
Users can assign a custom domain to their app with automatic SSL via cert-manager. Includes DNS verification flow (CNAME check), Persian instructions, admin-configurable pricing via PlatformSetting, and integration into the deploy wizard cost calculation. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { ServicePlan, BillingCycle, PricingResourceType, LifecycleSettings } from '@/types';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock } from 'lucide-react';
|
||||
import { DollarSign, Plus, Trash2, Edit2, ToggleLeft, ToggleRight, ChevronDown, ChevronUp, Shield, Clock, Globe } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
|
||||
const runtimeOptions = [
|
||||
@@ -34,9 +34,13 @@ const resourceLabels: Record<PricingResourceType, string> = {
|
||||
memory_per_gb: 'Memory (per GB)',
|
||||
storage_per_gb: 'Storage (per GB)',
|
||||
database_addon: 'Database Addon',
|
||||
redis_addon: 'Redis Addon',
|
||||
rabbitmq_addon: 'RabbitMQ Addon',
|
||||
elasticsearch_addon: 'Elasticsearch Addon',
|
||||
custom_domain_addon: 'Custom Domain + SSL',
|
||||
};
|
||||
|
||||
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon'];
|
||||
const allResourceTypes: PricingResourceType[] = ['base_fee', 'cpu_per_core', 'memory_per_gb', 'storage_per_gb', 'database_addon', 'redis_addon', 'rabbitmq_addon', 'elasticsearch_addon', 'custom_domain_addon'];
|
||||
|
||||
interface RuleForm {
|
||||
resourceType: PricingResourceType;
|
||||
@@ -323,12 +327,108 @@ export default function AdminBillingPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Custom Domain Pricing ───────────────────── */}
|
||||
<CustomDomainPricingSection />
|
||||
|
||||
{/* ─── Lifecycle Retention Settings ───────────────────── */}
|
||||
<LifecycleSettingsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing Sub-component ──────────────────────────
|
||||
|
||||
function CustomDomainPricingSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [priceInput, setPriceInput] = useState('');
|
||||
|
||||
const { data: priceData, isLoading } = useQuery<{ monthlyPrice: number }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (monthlyPrice: number) => api.patch('/billing/settings/custom-domain-price', { monthlyPrice }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||
toast.success('Custom domain pricing updated');
|
||||
setEditing(false);
|
||||
},
|
||||
onError: () => toast.error('Failed to update pricing'),
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
setPriceInput(String(priceData?.monthlyPrice || 0));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const price = Number(priceInput);
|
||||
if (isNaN(price) || price < 0) {
|
||||
toast.error('Price must be a non-negative number');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(price);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" /> Custom Domain Pricing
|
||||
</h2>
|
||||
{!editing && (
|
||||
<button onClick={handleEdit} className="btn-secondary text-sm flex items-center gap-1.5">
|
||||
<Edit2 className="w-4 h-4" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading...</p>
|
||||
) : editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monthly Price (Toman)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priceInput}
|
||||
onChange={(e) => setPriceInput(e.target.value)}
|
||||
className="input-field w-full max-w-xs"
|
||||
min="0"
|
||||
placeholder="e.g. 50000"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Set to 0 to make custom domains free. This price is added to the total cost when users enable a custom domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending} className="btn-primary text-sm">
|
||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Monthly price per custom domain</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{(priceData?.monthlyPrice || 0).toLocaleString('en-US')} <span className="text-sm font-normal text-gray-500">Toman</span>
|
||||
</p>
|
||||
</div>
|
||||
{priceData?.monthlyPrice === 0 && (
|
||||
<span className="badge badge-green">Free</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lifecycle Settings Sub-component ─────────────────────────────
|
||||
|
||||
function LifecycleSettingsSection() {
|
||||
|
||||
@@ -83,6 +83,10 @@ export default function AppDetailPage() {
|
||||
newCost: { hourly: number };
|
||||
} | null>(null);
|
||||
|
||||
// ── Custom Domain ──────────────────────────────────
|
||||
const [showDomainSetup, setShowDomainSetup] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
|
||||
const { data: app, isLoading } = useQuery<Application>({
|
||||
queryKey: ['application', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data),
|
||||
@@ -226,6 +230,60 @@ export default function AppDetailPage() {
|
||||
const needsRenewal = app?.lifecycleStatus === 'suspended' || app?.lifecycleStatus === 'pending_deletion';
|
||||
const isExpiringSoon = app?.planExpiresAt && new Date(app.planExpiresAt) <= new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
// ─── Custom Domain ──────────────────────────────────
|
||||
const { data: domainInfo, refetch: refetchDomainInfo } = useQuery<{
|
||||
customDomain: string | null;
|
||||
customDomainStatus: string;
|
||||
platformDomain: string;
|
||||
fullPlatformUrl: string;
|
||||
cnameTarget: string;
|
||||
instructions: string[];
|
||||
}>({
|
||||
queryKey: ['domain-info', appId],
|
||||
queryFn: () => api.get(`/applications/${appId}/domain`).then((r) => r.data),
|
||||
enabled: showDomainSetup || (!!app && (app.customDomainStatus === 'pending_dns' || app.customDomainStatus === 'verified')),
|
||||
});
|
||||
|
||||
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||
});
|
||||
|
||||
const setDomainMutation = useMutation({
|
||||
mutationFn: (domain: string) => api.post(`/applications/${appId}/domain`, { domain }),
|
||||
onSuccess: () => {
|
||||
toast.success('دامنه تنظیم شد. لطفاً رکورد DNS را اضافه کنید.');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
setCustomDomainInput('');
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تنظیم دامنه'),
|
||||
});
|
||||
|
||||
const verifyDnsMutation = useMutation({
|
||||
mutationFn: () => api.post(`/applications/${appId}/domain/verify`),
|
||||
onSuccess: (res) => {
|
||||
if (res.data.verified) {
|
||||
toast.success('دامنه با موفقیت تأیید شد!');
|
||||
} else {
|
||||
toast.warning(res.data.message || 'DNS هنوز آماده نیست. لطفاً بعداً تلاش کنید.');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در تأیید DNS'),
|
||||
});
|
||||
|
||||
const removeDomainMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/applications/${appId}/domain`),
|
||||
onSuccess: () => {
|
||||
toast.success('دامنه اختصاصی حذف شد');
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
refetchDomainInfo();
|
||||
},
|
||||
onError: (err: any) => toast.error(err.response?.data?.message || 'خطا در حذف دامنه'),
|
||||
});
|
||||
|
||||
// ─── Snapshots ──────────────────────────────────────
|
||||
const { data: snapshots = [], isLoading: snapshotsLoading } = useQuery<AppSnapshot[]>({
|
||||
queryKey: ['snapshots', appId],
|
||||
@@ -679,7 +737,7 @@ export default function AppDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.subdomain}.apps.cloudhost.local
|
||||
{app.runtime}{app.runtimeVersion ? ` v${app.runtimeVersion}` : ''}{app.phpVersion ? ` — PHP ${app.phpVersion}` : ''} · {app.customDomain && app.customDomainStatus === 'verified' ? app.customDomain : `${app.subdomain}.${domainInfo?.platformDomain || 'apps.cloudhost.ir'}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1206,6 +1264,168 @@ export default function AppDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Domain */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" /> دامنه
|
||||
</h2>
|
||||
{!showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||
<button
|
||||
onClick={() => setShowDomainSetup(true)}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
افزودن دامنه اختصاصی
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform domain (always shown) */}
|
||||
<div className="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">دامنه پلتفرم</p>
|
||||
<p className="text-sm font-mono font-medium text-gray-800">
|
||||
{app.subdomain}.{domainInfo?.platformDomain || 'apps.cloudhost.ir'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-green text-xs">فعال</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom domain - verified */}
|
||||
{app.customDomain && app.customDomainStatus === 'verified' && (
|
||||
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-emerald-600 mb-1">دامنه اختصاصی</p>
|
||||
<p className="text-sm font-mono font-medium text-emerald-800">{app.customDomain}</p>
|
||||
<p className="text-xs text-emerald-500 mt-1">
|
||||
<CheckCircle className="w-3 h-3 inline" /> SSL فعال — تأیید شده در{' '}
|
||||
{app.customDomainVerifiedAt ? new Date(app.customDomainVerifiedAt).toLocaleString('fa-IR') : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'حذف دامنه اختصاصی',
|
||||
message: `آیا مطمئن هستید که میخواهید دامنه "${app.customDomain}" را حذف کنید؟ وبسایت فقط از طریق دامنه پلتفرم قابل دسترسی خواهد بود.`,
|
||||
confirmText: 'حذف',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) removeDomainMutation.mutate();
|
||||
}}
|
||||
disabled={removeDomainMutation.isPending}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||
>
|
||||
{removeDomainMutation.isPending ? 'در حال حذف...' : 'حذف دامنه'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom domain - pending DNS */}
|
||||
{app.customDomain && app.customDomainStatus === 'pending_dns' && (
|
||||
<div className="bg-amber-50 rounded-xl p-4 mb-4 border border-amber-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-amber-600 mb-1">دامنه اختصاصی — در انتظار تأیید DNS</p>
|
||||
<p className="text-sm font-mono font-medium text-amber-800">{app.customDomain}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => verifyDnsMutation.mutate()}
|
||||
disabled={verifyDnsMutation.isPending}
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
{verifyDnsMutation.isPending ? 'در حال بررسی...' : 'تأیید DNS'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeDomainMutation.mutate()}
|
||||
disabled={removeDomainMutation.isPending}
|
||||
className="text-sm px-3 py-1.5 rounded-lg text-red-600 hover:bg-red-50 border border-red-200 transition-colors"
|
||||
>
|
||||
لغو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS Instructions */}
|
||||
{domainInfo?.instructions && (
|
||||
<div className="bg-white rounded-lg p-4 border border-amber-100">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">راهنمای تنظیم DNS</h4>
|
||||
<div className="space-y-2 text-sm text-gray-600" dir="rtl">
|
||||
{domainInfo.instructions.map((step, i) => (
|
||||
<p key={i} className={step.startsWith(' ') ? 'pr-4 text-xs font-mono bg-gray-50 rounded px-2 py-1' : ''}>
|
||||
{step}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-3 border border-blue-100">
|
||||
<p className="text-xs text-blue-700 font-medium mb-1">CNAME Target:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono text-blue-900 bg-blue-100 px-2 py-1 rounded flex-1">
|
||||
{domainInfo.fullPlatformUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(domainInfo.fullPlatformUrl);
|
||||
toast.success('کپی شد!');
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 p-1"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain setup form */}
|
||||
{showDomainSetup && (!app.customDomain || app.customDomainStatus === 'none') && (
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">تنظیم دامنه اختصاصی</h4>
|
||||
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 mb-4 border border-blue-100">
|
||||
<p className="text-sm text-blue-700">
|
||||
<CreditCard className="w-4 h-4 inline ml-1" />
|
||||
هزینه دامنه اختصاصی: <strong>{domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان / ماهانه</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
این هزینه در محاسبه کلی هزینهها در نظر گرفته میشود.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2" dir="ltr">
|
||||
<input
|
||||
type="text"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
placeholder="example.com or www.example.com"
|
||||
className="input-field flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customDomainInput.trim()) setDomainMutation.mutate(customDomainInput.trim());
|
||||
}}
|
||||
disabled={!customDomainInput.trim() || setDomainMutation.isPending}
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{setDomainMutation.isPending ? 'در حال ثبت...' : 'ثبت دامنه'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowDomainSetup(false); setCustomDomainInput(''); }}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Database Info & Dump Upload */}
|
||||
{app.databaseType !== 'none' && (
|
||||
<div className="card">
|
||||
|
||||
@@ -7,7 +7,7 @@ import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } 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 } 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 } from 'lucide-react';
|
||||
|
||||
const steps = ['Basic Info', 'Versions & Database', 'Resources', 'Review'];
|
||||
|
||||
@@ -50,8 +50,11 @@ export default function DeployPage() {
|
||||
dbStorageSize: '1',
|
||||
appStorageSize: '2',
|
||||
enableRedis: false,
|
||||
redisVersion: '7.2',
|
||||
enableRabbitmq: false,
|
||||
rabbitmqVersion: '3.13',
|
||||
enableElasticsearch: false,
|
||||
elasticsearchVersion: '8.12',
|
||||
});
|
||||
const [envKey, setEnvKey] = useState('');
|
||||
const [envVal, setEnvVal] = useState('');
|
||||
@@ -86,9 +89,19 @@ export default function DeployPage() {
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
// ── Custom Domain ──────────────────────────────
|
||||
const [enableCustomDomain, setEnableCustomDomain] = useState(false);
|
||||
const [customDomainInput, setCustomDomainInput] = useState('');
|
||||
|
||||
const { data: domainPriceData } = useQuery<{ monthlyPrice: number }>({
|
||||
queryKey: ['custom-domain-price'],
|
||||
queryFn: () => api.get('/billing/settings/custom-domain-price').then((r) => r.data),
|
||||
enabled: step >= 2,
|
||||
});
|
||||
|
||||
// Cost calculation for the review step
|
||||
const { data: costData, isLoading: costLoading } = useQuery<CostBreakdown>({
|
||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch],
|
||||
queryKey: ['deploy-cost', form.runtime, form.databaseType, form.cpuLimit, form.memoryLimit, form.replicas, form.dbStorageSize, form.appStorageSize, form.enableRedis, form.enableRabbitmq, form.enableElasticsearch, enableCustomDomain],
|
||||
queryFn: () => api.post('/billing/calculate', {
|
||||
runtime: form.runtime,
|
||||
databaseType: form.databaseType,
|
||||
@@ -100,6 +113,7 @@ export default function DeployPage() {
|
||||
enableRedis: form.enableRedis,
|
||||
enableRabbitmq: form.enableRabbitmq,
|
||||
enableElasticsearch: form.enableElasticsearch,
|
||||
enableCustomDomain,
|
||||
}).then((r) => r.data),
|
||||
enabled: step === 3,
|
||||
});
|
||||
@@ -330,6 +344,9 @@ export default function DeployPage() {
|
||||
if (payload.appStorageSize) {
|
||||
payload.appStorageSize = `${parseInt(payload.appStorageSize, 10) || 2}Gi`;
|
||||
}
|
||||
if (enableCustomDomain && customDomainInput.trim()) {
|
||||
payload.customDomain = customDomainInput.trim();
|
||||
}
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
@@ -1257,93 +1274,202 @@ export default function DeployPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* Redis */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableRedis
|
||||
? 'border-red-400 bg-red-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
</svg>
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRedis: !form.enableRedis })}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRedis ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRedis ? 'text-red-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2ZM2 17L12 22L22 17M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Redis</p>
|
||||
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Redis</p>
|
||||
<p className="text-xs text-gray-500">In-memory cache & store</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{form.enableRedis && (
|
||||
<div className="mt-3 pt-3 border-t border-red-200 text-xs text-red-600">
|
||||
<p>REDIS_HOST, REDIS_PASSWORD, REDIS_URL will be available</p>
|
||||
<div className="mt-3 pt-3 border-t border-red-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Version:</label>
|
||||
<select
|
||||
className="text-xs border border-red-200 rounded px-2 py-1 bg-white"
|
||||
value={form.redisVersion || '7.2'}
|
||||
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="7.2">7.2 (Latest)</option>
|
||||
<option value="7.0">7.0</option>
|
||||
<option value="6.2">6.2 (LTS)</option>
|
||||
<option value="6.0">6.0</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-red-600">REDIS_HOST, REDIS_PASSWORD, REDIS_URL</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* RabbitMQ */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableRabbitmq
|
||||
? 'border-orange-400 bg-orange-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
|
||||
</svg>
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableRabbitmq: !form.enableRabbitmq })}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableRabbitmq ? 'bg-orange-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableRabbitmq ? 'text-orange-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3H3v18h18V3zM8 17H5v-3h3v3zm5-4h-3v-3h3v3zm5 0h-3v-3h3v3zm0-4h-8V6h8v3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">RabbitMQ</p>
|
||||
<p className="text-xs text-gray-500">Message broker</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">RabbitMQ</p>
|
||||
<p className="text-xs text-gray-500">Message broker</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{form.enableRabbitmq && (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200 text-xs text-orange-600">
|
||||
<p>RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL will be available</p>
|
||||
<div className="mt-3 pt-3 border-t border-orange-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Version:</label>
|
||||
<select
|
||||
className="text-xs border border-orange-200 rounded px-2 py-1 bg-white"
|
||||
value={form.rabbitmqVersion || '3.13'}
|
||||
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||
>
|
||||
<option value="3.13">3.13 (Latest)</option>
|
||||
<option value="3.12">3.12 (LTS)</option>
|
||||
<option value="3.11">3.11</option>
|
||||
<option value="3.10">3.10</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-orange-600">RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Elasticsearch */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
<div className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.enableElasticsearch
|
||||
? 'border-yellow-400 bg-yellow-50 shadow-sm'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
|
||||
</svg>
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, enableElasticsearch: !form.enableElasticsearch })}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${form.enableElasticsearch ? 'bg-yellow-100' : 'bg-gray-100'}`}>
|
||||
<svg className={`w-6 h-6 ${form.enableElasticsearch ? 'text-yellow-500' : 'text-gray-400'}`} viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" fill="none"/>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" stroke="currentColor" strokeWidth="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
||||
<p className="text-xs text-gray-500">Centralized logging</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Elasticsearch</p>
|
||||
<p className="text-xs text-gray-500">Logging & search</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{form.enableElasticsearch && (
|
||||
<div className="mt-3 pt-3 border-t border-yellow-200 text-xs text-yellow-600">
|
||||
<p>Logs collected via Fluent Bit sidecar</p>
|
||||
<div className="mt-3 pt-3 border-t border-yellow-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Version:</label>
|
||||
<select
|
||||
className="text-xs border border-yellow-200 rounded px-2 py-1 bg-white"
|
||||
value={form.elasticsearchVersion || '8.12'}
|
||||
onChange={(e) => setForm({ ...form, elasticsearchVersion: e.target.value })}
|
||||
>
|
||||
<option value="8.12">8.12 (Latest)</option>
|
||||
<option value="8.11">8.11</option>
|
||||
<option value="7.17">7.17 (LTS)</option>
|
||||
<option value="7.10">7.10</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-600">Logs collected via Fluent Bit sidecar</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Paths Configuration - shown when Elasticsearch is enabled */}
|
||||
{form.enableElasticsearch && (
|
||||
<div className="mt-4 p-4 bg-yellow-50/50 border border-yellow-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg className="w-5 h-5 text-yellow-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<h4 className="text-sm font-semibold text-gray-800">Log File Paths</h4>
|
||||
<span className="text-xs text-gray-400">(optional)</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Specify which log files to collect. Leave empty for default paths based on runtime.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(form.logPaths || []).map((path, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
className="input-field flex-1 text-sm"
|
||||
value={path}
|
||||
onChange={(e) => {
|
||||
const newPaths = [...(form.logPaths || [])];
|
||||
newPaths[idx] = e.target.value;
|
||||
setForm({ ...form, logPaths: newPaths });
|
||||
}}
|
||||
placeholder="/var/log/app/*.log"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newPaths = (form.logPaths || []).filter((_, i) => i !== idx);
|
||||
setForm({ ...form, logPaths: newPaths });
|
||||
}}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, logPaths: [...(form.logPaths || []), ''] })}
|
||||
className="text-sm text-yellow-600 hover:text-yellow-700 font-medium"
|
||||
>
|
||||
+ Add log path
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-white/50 rounded-lg">
|
||||
<p className="text-xs text-gray-500">
|
||||
<strong>Default paths by runtime:</strong><br/>
|
||||
• Node.js/Go/Python: <code className="bg-gray-100 px-1 rounded">/app/logs/*.log</code><br/>
|
||||
• Laravel/PHP: <code className="bg-gray-100 px-1 rounded">/var/www/html/storage/logs/*.log</code><br/>
|
||||
• WordPress: <code className="bg-gray-100 px-1 rounded">/var/www/html/wp-content/debug.log</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form.enableRedis || form.enableRabbitmq || form.enableElasticsearch) && (
|
||||
<p className="mt-4 text-xs text-gray-500">
|
||||
Each enabled service adds to the monthly cost. Services are deployed in your namespace and not shared.
|
||||
{form.enableElasticsearch
|
||||
? 'Logs are sent to a centralized Elasticsearch cluster. View your logs in Kibana dashboard.'
|
||||
: 'Each enabled service adds to the monthly cost. Services are deployed in your namespace.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1803,6 +1929,55 @@ export default function DeployPage() {
|
||||
<span className="text-sm font-medium">{Object.keys(form.envVars!).length} defined</span>
|
||||
</div>
|
||||
)}
|
||||
{enableCustomDomain && customDomainInput && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Custom Domain</span>
|
||||
<span className="text-sm font-medium font-mono">{customDomainInput}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Domain Option */}
|
||||
<div className="bg-white rounded-xl p-5 border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
|
||||
<Globe className="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">دامنه اختصاصی</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
وبسایت را روی دامنه خود ببینید (با SSL رایگان)
|
||||
{domainPriceData && domainPriceData.monthlyPrice > 0 && (
|
||||
<span className="text-purple-600 font-medium"> — {domainPriceData.monthlyPrice.toLocaleString('fa-IR')} تومان/ماه</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnableCustomDomain(!enableCustomDomain)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${enableCustomDomain ? 'bg-purple-600' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${enableCustomDomain ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{enableCustomDomain && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">آدرس دامنه</label>
|
||||
<input
|
||||
type="text"
|
||||
dir="ltr"
|
||||
value={customDomainInput}
|
||||
onChange={(e) => setCustomDomainInput(e.target.value)}
|
||||
placeholder="example.com or www.example.com"
|
||||
className="input-field w-full font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-2" dir="rtl">
|
||||
بعد از دیپلوی، باید رکورد DNS دامنه خود را تنظیم کنید. راهنمای کامل در صفحه جزئیات اپلیکیشن نمایش داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
|
||||
Reference in New Issue
Block a user