Localize database-config, snapshots panel and new-service wizard.
Add components.dbConfig / components.snapshots and dashboard.servicesNew dictionaries; move the managed-database config (engine, credentials, dump upload, storage), the snapshots panel and the three-step new-service wizard onto them. validateDbDumpStorage now takes a localized template. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { Link } from '@/i18n/Link';
|
||||||
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
|
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||||
import type {
|
import type {
|
||||||
Application,
|
Application,
|
||||||
CreateApplicationDto,
|
CreateApplicationDto,
|
||||||
@@ -37,11 +38,12 @@ import {
|
|||||||
|
|
||||||
type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
|
type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
|
||||||
|
|
||||||
const steps = ['Service type', 'Configuration', 'Review & pay'];
|
|
||||||
|
|
||||||
export default function NewManagedServicePage() {
|
export default function NewManagedServicePage() {
|
||||||
|
const t = useT();
|
||||||
|
const s = t.dashboard.servicesNew;
|
||||||
|
const steps = s.steps;
|
||||||
const { notifyDeployStarted } = useDeployProgressActions();
|
const { notifyDeployStarted } = useDeployProgressActions();
|
||||||
const router = useRouter();
|
const router = useLocalizedRouter();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [kind, setKind] = useState<ServiceKind | null>(null);
|
const [kind, setKind] = useState<ServiceKind | null>(null);
|
||||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||||
@@ -181,10 +183,10 @@ export default function NewManagedServicePage() {
|
|||||||
await api.post(`/deployments/applications/${appId}/deploy`);
|
await api.post(`/deployments/applications/${appId}/deploy`);
|
||||||
} catch {
|
} catch {
|
||||||
useDeployProgressStore.getState().stopTracking(appId);
|
useDeployProgressStore.getState().stopTracking(appId);
|
||||||
throw new Error('Deploy failed');
|
throw new Error(s.deployFailed);
|
||||||
}
|
}
|
||||||
setDeployStage('done');
|
setDeployStage('done');
|
||||||
toast.success('Service provisioned successfully');
|
toast.success(s.provisionedSuccess);
|
||||||
router.push(`/dashboard/services/${appId}`);
|
router.push(`/dashboard/services/${appId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -217,12 +219,12 @@ export default function NewManagedServicePage() {
|
|||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId, form.name).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error('Payment succeeded but deployment failed');
|
toast.error(s.paySucceededDeployFailed);
|
||||||
}),
|
}),
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
toast.error(msg || 'Payment or provisioning failed');
|
toast.error(msg || s.payProvisionFailed);
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -233,7 +235,7 @@ export default function NewManagedServicePage() {
|
|||||||
if (payAmount > 0) {
|
if (payAmount > 0) {
|
||||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||||
amount: payAmount,
|
amount: payAmount,
|
||||||
description: `Service: ${form.name} (${selectedCycle})`,
|
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
|
||||||
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
||||||
});
|
});
|
||||||
await api.post('/billing/gateway/verify', {
|
await api.post('/billing/gateway/verify', {
|
||||||
@@ -253,19 +255,19 @@ export default function NewManagedServicePage() {
|
|||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId, form.name).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error('Payment succeeded but deployment failed');
|
toast.error(s.paySucceededDeployFailed);
|
||||||
}),
|
}),
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
toast.error(msg || 'Payment failed');
|
toast.error(msg || s.payFailed);
|
||||||
setTimeout(() => setDeployStage('idle'), 2000);
|
setTimeout(() => setDeployStage('idle'), 2000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const validateBeforePay = () => {
|
const validateBeforePay = () => {
|
||||||
if (kind === 'managed_database' && dbDumpFile) {
|
if (kind === 'managed_database' && dbDumpFile) {
|
||||||
const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1);
|
const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1, t.components.dbConfig.storageTooSmallDetail);
|
||||||
if (err) {
|
if (err) {
|
||||||
setRestoreStorageErrorMessage(err);
|
setRestoreStorageErrorMessage(err);
|
||||||
setShowRestoreStorageErrorModal(true);
|
setShowRestoreStorageErrorModal(true);
|
||||||
@@ -280,7 +282,7 @@ export default function NewManagedServicePage() {
|
|||||||
if (payAmount === 0) walletPayMutation.mutate();
|
if (payAmount === 0) walletPayMutation.mutate();
|
||||||
else if (paymentMethod === 'wallet') {
|
else if (paymentMethod === 'wallet') {
|
||||||
if (!hasEnoughBalance) {
|
if (!hasEnoughBalance) {
|
||||||
toast.error('Insufficient wallet balance');
|
toast.error(s.insufficientBalance);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
walletPayMutation.mutate();
|
walletPayMutation.mutate();
|
||||||
@@ -299,11 +301,11 @@ export default function NewManagedServicePage() {
|
|||||||
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/dashboard/services" className="btn-ghost">
|
<Link href="/dashboard/services" className="btn-ghost">
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4 rtl:rotate-180" />
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">New managed service</h1>
|
<h1 className="page-title">{s.title}</h1>
|
||||||
<p className="page-subtitle">Database, Redis, or RabbitMQ — billed like applications</p>
|
<p className="page-subtitle">{s.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -338,9 +340,9 @@ export default function NewManagedServicePage() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ id: 'managed_database' as const, title: 'Database', desc: 'PostgreSQL, MySQL, MariaDB, MongoDB' },
|
{ id: 'managed_database' as const, title: s.typeDatabase, desc: s.typeDatabaseDesc },
|
||||||
{ id: 'managed_redis' as const, title: 'Redis', desc: 'In-memory cache & store' },
|
{ id: 'managed_redis' as const, title: s.typeRedis, desc: s.typeRedisDesc },
|
||||||
{ id: 'managed_rabbitmq' as const, title: 'RabbitMQ', desc: 'Message broker' },
|
{ id: 'managed_rabbitmq' as const, title: s.typeRabbitmq, desc: s.typeRabbitmqDesc },
|
||||||
] as const
|
] as const
|
||||||
).map((opt) => (
|
).map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -380,14 +382,14 @@ export default function NewManagedServicePage() {
|
|||||||
{step === 1 && kind && (
|
{step === 1 && kind && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Service name</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{s.serviceName}</label>
|
||||||
<input
|
<input
|
||||||
className="input-field"
|
className="input-field"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||||
placeholder="my-database"
|
placeholder={s.serviceNamePlaceholder}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers, and hyphens only</p>
|
<p className="text-xs text-gray-400 mt-1">{s.nameHint}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kind === 'managed_database' && (
|
{kind === 'managed_database' && (
|
||||||
@@ -413,7 +415,7 @@ export default function NewManagedServicePage() {
|
|||||||
{kind === 'managed_redis' && (
|
{kind === 'managed_redis' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Redis version</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{s.redisVersion}</label>
|
||||||
<select
|
<select
|
||||||
className="input-field max-w-xs"
|
className="input-field max-w-xs"
|
||||||
value={form.redisVersion}
|
value={form.redisVersion}
|
||||||
@@ -428,7 +430,7 @@ export default function NewManagedServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
{form.optionalServiceResources?.redis && (
|
{form.optionalServiceResources?.redis && (
|
||||||
<OptionalServiceResourceFields
|
<OptionalServiceResourceFields
|
||||||
title="Redis resources"
|
title={s.redisResources}
|
||||||
accentClass="text-red-500"
|
accentClass="text-red-500"
|
||||||
borderClass="border-red-400"
|
borderClass="border-red-400"
|
||||||
bgClass="bg-red-50"
|
bgClass="bg-red-50"
|
||||||
@@ -450,7 +452,7 @@ export default function NewManagedServicePage() {
|
|||||||
{kind === 'managed_rabbitmq' && (
|
{kind === 'managed_rabbitmq' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">RabbitMQ version</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{s.rabbitmqVersion}</label>
|
||||||
<select
|
<select
|
||||||
className="input-field max-w-xs"
|
className="input-field max-w-xs"
|
||||||
value={form.rabbitmqVersion}
|
value={form.rabbitmqVersion}
|
||||||
@@ -465,7 +467,7 @@ export default function NewManagedServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
{form.optionalServiceResources?.rabbitmq && (
|
{form.optionalServiceResources?.rabbitmq && (
|
||||||
<OptionalServiceResourceFields
|
<OptionalServiceResourceFields
|
||||||
title="RabbitMQ resources"
|
title={s.rabbitmqResources}
|
||||||
accentClass="text-orange-500"
|
accentClass="text-orange-500"
|
||||||
borderClass="border-orange-400"
|
borderClass="border-orange-400"
|
||||||
bgClass="bg-orange-50"
|
bgClass="bg-orange-50"
|
||||||
@@ -495,20 +497,20 @@ export default function NewManagedServicePage() {
|
|||||||
) : costData ? (
|
) : costData ? (
|
||||||
<>
|
<>
|
||||||
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-200">
|
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-200">
|
||||||
<p className="text-sm font-medium text-gray-700 mb-3">Billing cycle</p>
|
<p className="text-sm font-medium text-gray-700 mb-3">{s.billingCycle}</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||||
<button
|
<button
|
||||||
key={cycle}
|
key={cycle}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedCycle(cycle)}
|
onClick={() => setSelectedCycle(cycle)}
|
||||||
className={`py-3 rounded-lg border-2 text-sm font-medium capitalize ${
|
className={`py-3 rounded-lg border-2 text-sm font-medium ${
|
||||||
selectedCycle === cycle ? 'border-emerald-500 bg-white' : 'border-transparent bg-white/50'
|
selectedCycle === cycle ? 'border-emerald-500 bg-white' : 'border-transparent bg-white/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cycle}
|
{s.cycles[cycle]}
|
||||||
<span className="block text-lg font-bold text-emerald-700 mt-1">
|
<span className="block text-lg font-bold text-emerald-700 mt-1">
|
||||||
{Number(costData[cycle]).toLocaleString('en-US')} T
|
{Number(costData[cycle]).toLocaleString('en-US')} {t.common.currencyShort}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -516,7 +518,7 @@ export default function NewManagedServicePage() {
|
|||||||
</div>
|
</div>
|
||||||
{requiresPayment && (
|
{requiresPayment && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700 mb-2">Payment method</p>
|
<p className="text-sm font-medium text-gray-700 mb-2">{s.paymentMethod}</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -526,39 +528,39 @@ export default function NewManagedServicePage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Wallet className="w-5 h-5 text-primary-600" />
|
<Wallet className="w-5 h-5 text-primary-600" />
|
||||||
<p className="font-semibold text-sm mt-2">Wallet</p>
|
<p className="font-semibold text-sm mt-2">{s.wallet}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{Number(walletBalance).toLocaleString('en-US')} T
|
{Number(walletBalance).toLocaleString('en-US')} {t.common.currencyShort}
|
||||||
{!hasEnoughBalance && <span className="text-red-500 block">Insufficient</span>}
|
{!hasEnoughBalance && <span className="text-red-500 block">{s.insufficient}</span>}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPaymentMethod('gateway')}
|
onClick={() => setPaymentMethod('gateway')}
|
||||||
className={`p-4 rounded-xl border-2 text-left ${
|
className={`p-4 rounded-xl border-2 text-left rtl:text-right ${
|
||||||
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CreditCard className="w-5 h-5 text-emerald-600" />
|
<CreditCard className="w-5 h-5 text-emerald-600" />
|
||||||
<p className="font-semibold text-sm mt-2">Pay now</p>
|
<p className="font-semibold text-sm mt-2">{s.payNow}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 text-center">Pricing unavailable</p>
|
<p className="text-sm text-gray-500 text-center">{s.pricingUnavailable}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
|
||||||
<button type="button" onClick={() => setStep(step - 1)} disabled={step === 0} className="btn-ghost disabled:opacity-0">
|
<button type="button" onClick={() => setStep(step - 1)} disabled={step === 0} className="btn-ghost disabled:opacity-0">
|
||||||
← Back
|
<span className="rtl:hidden">← </span>{s.back}<span className="ltr:hidden"> →</span>
|
||||||
</button>
|
</button>
|
||||||
{step < 2 ? (
|
{step < 2 ? (
|
||||||
<button type="button" onClick={() => setStep(step + 1)} disabled={!canNext()} className="btn-primary disabled:opacity-50">
|
<button type="button" onClick={() => setStep(step + 1)} disabled={!canNext()} className="btn-primary disabled:opacity-50">
|
||||||
Next →
|
{s.next}<span className="rtl:hidden"> →</span><span className="ltr:hidden"> ←</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -570,7 +572,7 @@ export default function NewManagedServicePage() {
|
|||||||
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
|
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin inline" />
|
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||||||
) : (
|
) : (
|
||||||
'Pay & provision'
|
s.payProvision
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -580,7 +582,7 @@ export default function NewManagedServicePage() {
|
|||||||
{deployStage !== 'idle' && (
|
{deployStage !== 'idle' && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4">
|
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4">
|
||||||
<h3 className="font-bold text-gray-900">Provisioning service</h3>
|
<h3 className="font-bold text-gray-900">{s.provisioning}</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{deployStage === 'creating' ? (
|
{deployStage === 'creating' ? (
|
||||||
@@ -588,7 +590,7 @@ export default function NewManagedServicePage() {
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||||
)}
|
)}
|
||||||
Creating service
|
{s.creatingService}
|
||||||
</div>
|
</div>
|
||||||
{dbDumpFile && kind === 'managed_database' && (
|
{dbDumpFile && kind === 'managed_database' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -600,7 +602,7 @@ export default function NewManagedServicePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||||
)}
|
)}
|
||||||
Uploading database dump
|
{s.uploadingDump}
|
||||||
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
||||||
<span className="text-primary-600 font-semibold">{dbUploadProgress}%</span>
|
<span className="text-primary-600 font-semibold">{dbUploadProgress}%</span>
|
||||||
)}
|
)}
|
||||||
@@ -620,7 +622,7 @@ export default function NewManagedServicePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||||
)}
|
)}
|
||||||
Payment
|
{s.payment}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{deployStage === 'deploying' ? (
|
{deployStage === 'deploying' ? (
|
||||||
@@ -632,7 +634,7 @@ export default function NewManagedServicePage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||||
)}
|
)}
|
||||||
Deploying
|
{s.deploying}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { AppSnapshot } from '@/types';
|
import type { AppSnapshot } from '@/types';
|
||||||
import { formatBytes } from '@/lib/format-utils';
|
import { formatBytes } from '@/lib/format-utils';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +35,7 @@ function BackupProgressBar({ progress, label }: { progress: number; label?: stri
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-1.5">
|
<div className="mt-3 space-y-1.5">
|
||||||
<div className="flex justify-between text-xs text-blue-700">
|
<div className="flex justify-between text-xs text-blue-700">
|
||||||
<span>{label || 'Creating database dump…'}</span>
|
<span>{label}</span>
|
||||||
<span className="font-semibold tabular-nums">{pct}%</span>
|
<span className="font-semibold tabular-nums">{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
|
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
|
||||||
@@ -54,6 +55,10 @@ export function DatabaseSnapshotsPanel({
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
isDeployed: boolean;
|
isDeployed: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const sn = t.components.snapshots;
|
||||||
|
const locale = useLocale();
|
||||||
|
const snapStatusLabel = (s: string) => (sn.snapStatus as Record<string, string>)[s] ?? s;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [showPanel, setShowPanel] = useState(false);
|
const [showPanel, setShowPanel] = useState(false);
|
||||||
@@ -82,10 +87,10 @@ export function DatabaseSnapshotsPanel({
|
|||||||
snap.status === 'completed' &&
|
snap.status === 'completed' &&
|
||||||
snap.dbDumpPath
|
snap.dbDumpPath
|
||||||
) {
|
) {
|
||||||
toast.success('Backup ready — you can download the dump now');
|
toast.success(sn.backupReady);
|
||||||
}
|
}
|
||||||
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
|
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
|
||||||
toast.error(snap.errorMessage || 'Backup failed');
|
toast.error(snap.errorMessage || sn.backupFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevInProgressRef.current = inProgressIds;
|
prevInProgressRef.current = inProgressIds;
|
||||||
@@ -94,18 +99,18 @@ export function DatabaseSnapshotsPanel({
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api.post(
|
api.post(
|
||||||
`/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`,
|
`/snapshots/applications/${serviceId}?label=${encodeURIComponent(sn.defaultLabel)}`,
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowPanel(true);
|
setShowPanel(true);
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||||
toast.info('Backup started — dump in progress');
|
toast.info(sn.backupStarted);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
||||||
?.message;
|
?.message;
|
||||||
const text = Array.isArray(msg) ? msg.join(', ') : msg;
|
const text = Array.isArray(msg) ? msg.join(', ') : msg;
|
||||||
toast.error(text || 'Failed to create backup');
|
toast.error(text || sn.createFailed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,9 +125,9 @@ export function DatabaseSnapshotsPanel({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||||
toast.success('Backup deleted');
|
toast.success(sn.backupDeleted);
|
||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to delete backup'),
|
onError: () => toast.error(sn.deleteFailed),
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadSnapshotDb = (snapshotId: string) => {
|
const downloadSnapshotDb = (snapshotId: string) => {
|
||||||
@@ -139,16 +144,16 @@ export function DatabaseSnapshotsPanel({
|
|||||||
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
toast.success('Download started');
|
toast.success(sn.downloadStarted);
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('Failed to download database dump'));
|
.catch(() => toast.error(sn.downloadFailed));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (snap: AppSnapshot) => {
|
const handleDelete = async (snap: AppSnapshot) => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Delete backup?',
|
title: sn.deleteConfirmTitle,
|
||||||
message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`,
|
message: sn.deleteConfirmMessage.replace('{label}', snap.label || sn.defaultLabel),
|
||||||
confirmText: 'Delete',
|
confirmText: t.common.delete,
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (ok) deleteMutation.mutate(snap.id);
|
if (ok) deleteMutation.mutate(snap.id);
|
||||||
@@ -163,7 +168,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<History className="w-5 h-5" /> Snapshots
|
<History className="w-5 h-5" /> {sn.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -173,30 +178,30 @@ export function DatabaseSnapshotsPanel({
|
|||||||
className="btn-secondary text-sm disabled:opacity-50"
|
className="btn-secondary text-sm disabled:opacity-50"
|
||||||
title={
|
title={
|
||||||
!isDeployed
|
!isDeployed
|
||||||
? 'Deploy the service first'
|
? sn.deployFirst
|
||||||
: hasInProgress
|
: hasInProgress
|
||||||
? 'Wait for the current backup to finish'
|
? sn.waitCurrent
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{createMutation.isPending || hasInProgress ? (
|
{createMutation.isPending || hasInProgress ? (
|
||||||
<>
|
<>
|
||||||
<Clock className="w-3 h-3 inline animate-spin" /> Creating…
|
<Clock className="w-3 h-3 inline animate-spin" /> {sn.creating}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Camera className="w-3 h-3 inline" /> New Snapshot
|
<Camera className="w-3 h-3 inline" /> {sn.newSnapshot}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
|
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
|
||||||
{showPanel ? (
|
{showPanel ? (
|
||||||
<>
|
<>
|
||||||
<ChevronDown className="w-4 h-4 inline" /> Hide
|
<ChevronDown className="w-4 h-4 inline" /> {sn.hide}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<History className="w-4 h-4 inline" /> Show
|
<History className="w-4 h-4 inline" /> {sn.show}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -207,19 +212,17 @@ export function DatabaseSnapshotsPanel({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> store a SQL dump you can
|
<Camera className="w-3 h-3 inline" /> {sn.infoNote}
|
||||||
download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept;
|
|
||||||
oldest are removed automatically.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-400 text-sm">Loading backups…</div>
|
<div className="text-center py-8 text-gray-400 text-sm">{sn.loadingBackups}</div>
|
||||||
) : snapshots.length === 0 ? (
|
) : snapshots.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
<p className="text-gray-500 text-sm">{sn.noBackups}</p>
|
||||||
<p className="text-gray-400 text-xs mt-1">Click New backup to create your first database dump.</p>
|
<p className="text-gray-400 text-xs mt-1">{sn.noBackupsHint}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||||
@@ -237,36 +240,36 @@ export function DatabaseSnapshotsPanel({
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Database backup'}</p>
|
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || sn.defaultLabel}</p>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||||
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
|
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
|
{snap.type === 'pre_deploy' ? sn.auto : sn.manual}
|
||||||
</span>
|
</span>
|
||||||
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
|
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
|
||||||
{snap.status === 'in_progress' ? 'Dumping…' : snap.status}
|
{snap.status === 'in_progress' ? sn.dumping : snapStatusLabel(snap.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString()}</p>
|
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString(locale)}</p>
|
||||||
|
|
||||||
{snap.status === 'in_progress' && (
|
{snap.status === 'in_progress' && (
|
||||||
<BackupProgressBar
|
<BackupProgressBar
|
||||||
progress={snap.progress ?? 0}
|
progress={snap.progress ?? 0}
|
||||||
label={
|
label={
|
||||||
(snap.progress ?? 0) < 15
|
(snap.progress ?? 0) < 15
|
||||||
? 'Preparing dump…'
|
? sn.preparing
|
||||||
: (snap.progress ?? 0) < 90
|
: (snap.progress ?? 0) < 90
|
||||||
? 'Exporting database…'
|
? sn.exporting
|
||||||
: 'Finalizing…'
|
: sn.finalizing
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{snap.status === 'completed' && snap.dbDumpPath && (
|
{snap.status === 'completed' && snap.dbDumpPath && (
|
||||||
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||||
<Database className="w-3 h-3" /> Dump: {formatBytes(snap.dbDumpSize)}
|
<Database className="w-3 h-3" /> {sn.dump}: {formatBytes(snap.dbDumpSize)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{snap.errorMessage && (
|
{snap.errorMessage && (
|
||||||
@@ -281,7 +284,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadSnapshotDb(snap.id)}
|
onClick={() => downloadSnapshotDb(snap.id)}
|
||||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||||
title="Download database dump"
|
title={sn.downloadTitle}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -290,7 +293,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
onClick={() => handleDelete(snap)}
|
onClick={() => handleDelete(snap)}
|
||||||
disabled={deletingSnapshotId !== null}
|
disabled={deletingSnapshotId !== null}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
|
||||||
title="Delete backup"
|
title={sn.deleteTitle}
|
||||||
>
|
>
|
||||||
{deletingSnapshotId === snap.id ? (
|
{deletingSnapshotId === snap.id ? (
|
||||||
<Clock className="w-4 h-4 animate-spin" />
|
<Clock className="w-4 h-4 animate-spin" />
|
||||||
@@ -306,7 +309,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
onClick={() => handleDelete(snap)}
|
onClick={() => handleDelete(snap)}
|
||||||
disabled={deletingSnapshotId !== null}
|
disabled={deletingSnapshotId !== null}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0 disabled:opacity-50"
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0 disabled:opacity-50"
|
||||||
title="Remove failed backup"
|
title={sn.removeFailedTitle}
|
||||||
>
|
>
|
||||||
{deletingSnapshotId === snap.id ? (
|
{deletingSnapshotId === snap.id ? (
|
||||||
<Clock className="w-4 h-4 animate-spin" />
|
<Clock className="w-4 h-4 animate-spin" />
|
||||||
@@ -321,7 +324,7 @@ export function DatabaseSnapshotsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-400 text-center">Maximum 10 backups are kept.</p>
|
<p className="text-xs text-gray-400 text-center">{sn.maxKept}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||||||
|
|
||||||
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
||||||
@@ -45,6 +46,7 @@ export function ManagedDatabaseConfig({
|
|||||||
dbDumpFile: File | null;
|
dbDumpFile: File | null;
|
||||||
onDbDumpFileChange: (file: File | null) => void;
|
onDbDumpFileChange: (file: File | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useT().components.dbConfig;
|
||||||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -54,11 +56,11 @@ export function ManagedDatabaseConfig({
|
|||||||
|
|
||||||
const acceptDump = (f: File) => {
|
const acceptDump = (f: File) => {
|
||||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||||
toast.error('Allowed: .sql, .gz, .dump');
|
toast.error(c.allowedFormats);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (f.size > 500 * 1024 * 1024) {
|
if (f.size > 500 * 1024 * 1024) {
|
||||||
toast.error('Max 500MB');
|
toast.error(c.maxSize);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDbDumpFileChange(f);
|
onDbDumpFileChange(f);
|
||||||
@@ -70,7 +72,7 @@ export function ManagedDatabaseConfig({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Database engine</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{c.engine}</label>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
{DB_OPTIONS.map((opt) => (
|
{DB_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
@@ -98,7 +100,7 @@ export function ManagedDatabaseConfig({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
|
||||||
<select
|
<select
|
||||||
className="input-field max-w-xs"
|
className="input-field max-w-xs"
|
||||||
value={form.dbVersion}
|
value={form.dbVersion}
|
||||||
@@ -115,30 +117,30 @@ export function ManagedDatabaseConfig({
|
|||||||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="w-5 h-5 text-blue-500" />
|
<Database className="w-5 h-5 text-blue-500" />
|
||||||
<h3 className="text-sm font-semibold text-gray-800">Database credentials</h3>
|
<h3 className="text-sm font-semibold text-gray-800">{c.credentials}</h3>
|
||||||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</span>
|
<span className="text-xs text-gray-400">{c.credentialsOptional}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">Username</label>
|
<label className="block text-xs text-gray-500 mb-1">{c.username}</label>
|
||||||
<input
|
<input
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="appuser"
|
placeholder={c.usernamePlaceholder}
|
||||||
value={form.dbUsername}
|
value={form.dbUsername}
|
||||||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
<label className="block text-xs text-gray-500 mb-1">{c.password}</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
className="input-field pr-20"
|
className="input-field pr-20 rtl:pr-3 rtl:pl-20"
|
||||||
type={showDbPassword ? 'text' : 'password'}
|
type={showDbPassword ? 'text' : 'password'}
|
||||||
placeholder="Auto-generated"
|
placeholder={c.passwordPlaceholder}
|
||||||
value={form.dbPassword}
|
value={form.dbPassword}
|
||||||
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
|
<div className="absolute inset-y-0 right-0 rtl:right-auto rtl:left-0 flex items-center gap-1 pr-2 rtl:pr-0 rtl:pl-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -149,7 +151,7 @@ export function ManagedDatabaseConfig({
|
|||||||
setShowDbPassword(true);
|
setShowDbPassword(true);
|
||||||
}}
|
}}
|
||||||
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
||||||
title="Generate random password"
|
title={c.generatePassword}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -164,12 +166,10 @@ export function ManagedDatabaseConfig({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">{c.credentialsNote}</p>
|
||||||
Used for internal cluster access. Use temporary or permanent external access below after deploy.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<label className="block text-xs text-gray-500 mb-2">Optional: upload DB dump to restore at creation</label>
|
<label className="block text-xs text-gray-500 mb-2">{c.uploadDumpLabel}</label>
|
||||||
<div
|
<div
|
||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -204,7 +204,7 @@ export function ManagedDatabaseConfig({
|
|||||||
/>
|
/>
|
||||||
{dbDumpFile ? (
|
{dbDumpFile ? (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-left">
|
<div className="text-sm text-left rtl:text-right">
|
||||||
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
||||||
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,20 +216,20 @@ export function ManagedDatabaseConfig({
|
|||||||
}}
|
}}
|
||||||
className="text-sm text-red-500"
|
className="text-sm text-red-500"
|
||||||
>
|
>
|
||||||
Remove
|
{c.remove}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
|
<p className="text-sm text-gray-700">{c.uploadHint}</p>
|
||||||
<p className="text-xs text-gray-400">Optional • Max 500MB • .sql, .gz, .dump</p>
|
<p className="text-xs text-gray-400">{c.uploadConstraints}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<label className="block text-xs text-gray-500 mb-2">Database storage size</label>
|
<label className="block text-xs text-gray-500 mb-2">{c.storageSize}</label>
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
@@ -274,13 +274,13 @@ export function ManagedDatabaseConfig({
|
|||||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||||
{dbDumpFile && (
|
{dbDumpFile && (
|
||||||
<span className="text-xs text-blue-500">
|
<span className="text-xs text-blue-500">
|
||||||
Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit)
|
{c.suggestedFromDump.replace('{gi}', (dbDumpFile.size / ONE_GIB).toFixed(2))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-gray-400">
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
Minimum {minDbGiFromRestoreDump} GB
|
{c.minimumGb.replace('{n}', String(minDbGiFromRestoreDump))}
|
||||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation
|
{dbDumpFile ? c.mustFitDump : ''}{c.expansionOnly}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,14 +288,22 @@ export function ManagedDatabaseConfig({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the chosen DB storage fits the uploaded dump. Pass the localized
|
||||||
|
* message template (components.dbConfig.storageTooSmallDetail) with {dumpGi}/{need}/{selected}.
|
||||||
|
*/
|
||||||
export function validateDbDumpStorage(
|
export function validateDbDumpStorage(
|
||||||
dbDumpFile: File | null,
|
dbDumpFile: File | null,
|
||||||
dbStorageSizeGi: number,
|
dbStorageSizeGi: number,
|
||||||
|
detailTemplate: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!dbDumpFile) return null;
|
if (!dbDumpFile) return null;
|
||||||
const need = minGiToFitFileBytes(dbDumpFile.size);
|
const need = minGiToFitFileBytes(dbDumpFile.size);
|
||||||
if (dbStorageSizeGi < need) {
|
if (dbStorageSizeGi < need) {
|
||||||
return `Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbStorageSizeGi} GiB). Increase database storage, then try again.`;
|
return detailTemplate
|
||||||
|
.replace('{dumpGi}', (dbDumpFile.size / ONE_GIB).toFixed(2))
|
||||||
|
.replace('{need}', String(need))
|
||||||
|
.replace('{selected}', String(dbStorageSizeGi));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -309,6 +317,7 @@ export function RestoreStorageErrorModal({
|
|||||||
message: string;
|
message: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const c = useT().components.dbConfig;
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
@@ -322,12 +331,12 @@ export function RestoreStorageErrorModal({
|
|||||||
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
|
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
|
||||||
<AlertCircle className="w-6 h-6 text-red-600" />
|
<AlertCircle className="w-6 h-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-bold text-gray-900 mb-2">Storage too small</h3>
|
<h3 className="text-lg font-bold text-gray-900 mb-2">{c.storageTooSmallTitle}</h3>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
|
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-3 p-6">
|
<div className="flex items-center justify-end gap-3 p-6">
|
||||||
<button type="button" onClick={onClose} className="btn-primary">
|
<button type="button" onClick={onClose} className="btn-primary">
|
||||||
OK
|
{c.ok}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -252,6 +252,69 @@ const en: Dictionary = {
|
|||||||
mShort: 'm',
|
mShort: 'm',
|
||||||
sShort: 's',
|
sShort: 's',
|
||||||
},
|
},
|
||||||
|
dbConfig: {
|
||||||
|
engine: 'Database engine',
|
||||||
|
version: 'Version',
|
||||||
|
credentials: 'Database credentials',
|
||||||
|
credentialsOptional: '(optional — auto-generated if empty)',
|
||||||
|
username: 'Username',
|
||||||
|
usernamePlaceholder: 'appuser',
|
||||||
|
password: 'Password',
|
||||||
|
passwordPlaceholder: 'Auto-generated',
|
||||||
|
generatePassword: 'Generate random password',
|
||||||
|
credentialsNote: 'Used for internal cluster access. Use temporary or permanent external access below after deploy.',
|
||||||
|
uploadDumpLabel: 'Optional: upload DB dump to restore at creation',
|
||||||
|
remove: 'Remove',
|
||||||
|
uploadHint: 'Upload a SQL dump to be restored after the database is created',
|
||||||
|
uploadConstraints: 'Optional • Max 500MB • .sql, .gz, .dump',
|
||||||
|
allowedFormats: 'Allowed: .sql, .gz, .dump',
|
||||||
|
maxSize: 'Max 500MB',
|
||||||
|
storageSize: 'Database storage size',
|
||||||
|
suggestedFromDump: 'Suggested from dump ({gi} GiB min fit)',
|
||||||
|
minimumGb: 'Minimum {n} GB',
|
||||||
|
mustFitDump: ' (must fit the uploaded dump)',
|
||||||
|
expansionOnly: ' • Only expansion allowed after creation',
|
||||||
|
storageTooSmallTitle: 'Storage too small',
|
||||||
|
ok: 'OK',
|
||||||
|
storageTooSmallDetail: 'Your database dump is about {dumpGi} GiB. Database storage must be at least {need} GiB (you selected {selected} GiB). Increase database storage, then try again.',
|
||||||
|
},
|
||||||
|
snapshots: {
|
||||||
|
title: 'Snapshots',
|
||||||
|
newSnapshot: 'New Snapshot',
|
||||||
|
creating: 'Creating…',
|
||||||
|
hide: 'Hide',
|
||||||
|
show: 'Show',
|
||||||
|
deployFirst: 'Deploy the service first',
|
||||||
|
waitCurrent: 'Wait for the current backup to finish',
|
||||||
|
infoNote: 'Database snapshots store a SQL dump you can download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept; oldest are removed automatically.',
|
||||||
|
loadingBackups: 'Loading backups…',
|
||||||
|
noBackups: 'No backups yet',
|
||||||
|
noBackupsHint: 'Click New backup to create your first database dump.',
|
||||||
|
defaultLabel: 'Database backup',
|
||||||
|
auto: 'Auto',
|
||||||
|
manual: 'Manual',
|
||||||
|
dumping: 'Dumping…',
|
||||||
|
preparing: 'Preparing dump…',
|
||||||
|
exporting: 'Exporting database…',
|
||||||
|
finalizing: 'Finalizing…',
|
||||||
|
creatingDump: 'Creating database dump…',
|
||||||
|
dump: 'Dump',
|
||||||
|
downloadTitle: 'Download database dump',
|
||||||
|
deleteTitle: 'Delete backup',
|
||||||
|
removeFailedTitle: 'Remove failed backup',
|
||||||
|
maxKept: 'Maximum 10 backups are kept.',
|
||||||
|
backupReady: 'Backup ready — you can download the dump now',
|
||||||
|
backupFailed: 'Backup failed',
|
||||||
|
backupStarted: 'Backup started — dump in progress',
|
||||||
|
createFailed: 'Failed to create backup',
|
||||||
|
backupDeleted: 'Backup deleted',
|
||||||
|
deleteFailed: 'Failed to delete backup',
|
||||||
|
downloadStarted: 'Download started',
|
||||||
|
downloadFailed: 'Failed to download database dump',
|
||||||
|
deleteConfirmTitle: 'Delete backup?',
|
||||||
|
deleteConfirmMessage: 'Delete “{label}”? The dump file will be permanently removed.',
|
||||||
|
snapStatus: { completed: 'completed', failed: 'failed' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
@@ -802,6 +865,46 @@ const en: Dictionary = {
|
|||||||
hours: 'hours',
|
hours: 'hours',
|
||||||
days: 'days',
|
days: 'days',
|
||||||
},
|
},
|
||||||
|
servicesNew: {
|
||||||
|
steps: ['Service type', 'Configuration', 'Review & pay'],
|
||||||
|
title: 'New managed service',
|
||||||
|
subtitle: 'Database, Redis, or RabbitMQ — billed like applications',
|
||||||
|
typeDatabase: 'Database',
|
||||||
|
typeDatabaseDesc: 'PostgreSQL, MySQL, MariaDB, MongoDB',
|
||||||
|
typeRedis: 'Redis',
|
||||||
|
typeRedisDesc: 'In-memory cache & store',
|
||||||
|
typeRabbitmq: 'RabbitMQ',
|
||||||
|
typeRabbitmqDesc: 'Message broker',
|
||||||
|
serviceName: 'Service name',
|
||||||
|
serviceNamePlaceholder: 'my-database',
|
||||||
|
nameHint: 'Lowercase letters, numbers, and hyphens only',
|
||||||
|
redisVersion: 'Redis version',
|
||||||
|
rabbitmqVersion: 'RabbitMQ version',
|
||||||
|
redisResources: 'Redis resources',
|
||||||
|
rabbitmqResources: 'RabbitMQ resources',
|
||||||
|
billingCycle: 'Billing cycle',
|
||||||
|
cycles: { hourly: 'Hourly', monthly: 'Monthly', yearly: 'Yearly' },
|
||||||
|
paymentMethod: 'Payment method',
|
||||||
|
wallet: 'Wallet',
|
||||||
|
insufficient: 'Insufficient',
|
||||||
|
payNow: 'Pay now',
|
||||||
|
pricingUnavailable: 'Pricing unavailable',
|
||||||
|
back: 'Back',
|
||||||
|
next: 'Next',
|
||||||
|
payProvision: 'Pay & provision',
|
||||||
|
provisioning: 'Provisioning service',
|
||||||
|
creatingService: 'Creating service',
|
||||||
|
uploadingDump: 'Uploading database dump',
|
||||||
|
payment: 'Payment',
|
||||||
|
deploying: 'Deploying',
|
||||||
|
provisionedSuccess: 'Service provisioned successfully',
|
||||||
|
deployFailed: 'Deploy failed',
|
||||||
|
paySucceededDeployFailed: 'Payment succeeded but deployment failed',
|
||||||
|
payProvisionFailed: 'Payment or provisioning failed',
|
||||||
|
payFailed: 'Payment failed',
|
||||||
|
insufficientBalance: 'Insufficient wallet balance',
|
||||||
|
serviceDesc: 'Service: {name} ({cycle})',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,69 @@ const fa = {
|
|||||||
mShort: 'د',
|
mShort: 'د',
|
||||||
sShort: 'ث',
|
sShort: 'ث',
|
||||||
},
|
},
|
||||||
|
dbConfig: {
|
||||||
|
engine: 'موتور دیتابیس',
|
||||||
|
version: 'نسخه',
|
||||||
|
credentials: 'اطلاعات ورود دیتابیس',
|
||||||
|
credentialsOptional: '(اختیاری — اگر خالی باشد خودکار ساخته میشود)',
|
||||||
|
username: 'نام کاربری',
|
||||||
|
usernamePlaceholder: 'appuser',
|
||||||
|
password: 'رمز عبور',
|
||||||
|
passwordPlaceholder: 'تولید خودکار',
|
||||||
|
generatePassword: 'تولید رمز تصادفی',
|
||||||
|
credentialsNote: 'برای دسترسی داخلی کلاستر استفاده میشود. پس از انتشار از دسترسی خارجی موقت یا دائمی زیر استفاده کن.',
|
||||||
|
uploadDumpLabel: 'اختیاری: آپلود dump دیتابیس برای بازیابی هنگام ساخت',
|
||||||
|
remove: 'حذف',
|
||||||
|
uploadHint: 'یک SQL dump آپلود کن تا پس از ساخت دیتابیس بازیابی شود',
|
||||||
|
uploadConstraints: 'اختیاری • حداکثر ۵۰۰ مگابایت • .sql, .gz, .dump',
|
||||||
|
allowedFormats: 'مجاز: .sql، .gz، .dump',
|
||||||
|
maxSize: 'حداکثر ۵۰۰ مگابایت',
|
||||||
|
storageSize: 'اندازهٔ فضای ذخیرهٔ دیتابیس',
|
||||||
|
suggestedFromDump: 'پیشنهادشده از dump (حداقل {gi} GiB)',
|
||||||
|
minimumGb: 'حداقل {n} GB',
|
||||||
|
mustFitDump: ' (باید dump آپلودشده را جا دهد)',
|
||||||
|
expansionOnly: ' • فقط افزایش پس از ساخت مجاز است',
|
||||||
|
storageTooSmallTitle: 'فضای ذخیره خیلی کم است',
|
||||||
|
ok: 'باشه',
|
||||||
|
storageTooSmallDetail: 'حجم dump دیتابیس شما حدود {dumpGi} GiB است. فضای ذخیرهٔ دیتابیس باید حداقل {need} GiB باشد (شما {selected} GiB انتخاب کردی). فضای ذخیره را افزایش بده و دوباره تلاش کن.',
|
||||||
|
},
|
||||||
|
snapshots: {
|
||||||
|
title: 'اسنپشاتها',
|
||||||
|
newSnapshot: 'اسنپشات جدید',
|
||||||
|
creating: 'در حال ساخت…',
|
||||||
|
hide: 'پنهان',
|
||||||
|
show: 'نمایش',
|
||||||
|
deployFirst: 'اول سرویس را منتشر کن',
|
||||||
|
waitCurrent: 'منتظر پایان بکاپ فعلی بمان',
|
||||||
|
infoNote: 'اسنپشاتهای دیتابیس یک SQL dump ذخیره میکنند که بعداً میتوانی دانلود کنی. وقتی پیشرفت به ۱۰۰٪ رسید از دکمهٔ دانلود استفاده کن. تا ۱۰ اسنپشات نگه داشته میشود؛ قدیمیترها خودکار حذف میشوند.',
|
||||||
|
loadingBackups: 'در حال بارگذاری بکاپها…',
|
||||||
|
noBackups: 'هنوز بکاپی نیست',
|
||||||
|
noBackupsHint: 'برای ساخت اولین dump دیتابیس روی «اسنپشات جدید» بزن.',
|
||||||
|
defaultLabel: 'بکاپ دیتابیس',
|
||||||
|
auto: 'خودکار',
|
||||||
|
manual: 'دستی',
|
||||||
|
dumping: 'در حال dump…',
|
||||||
|
preparing: 'آمادهسازی dump…',
|
||||||
|
exporting: 'استخراج دیتابیس…',
|
||||||
|
finalizing: 'نهاییسازی…',
|
||||||
|
creatingDump: 'ساخت dump دیتابیس…',
|
||||||
|
dump: 'dump',
|
||||||
|
downloadTitle: 'دانلود dump دیتابیس',
|
||||||
|
deleteTitle: 'حذف بکاپ',
|
||||||
|
removeFailedTitle: 'حذف بکاپ ناموفق',
|
||||||
|
maxKept: 'حداکثر ۱۰ بکاپ نگه داشته میشود.',
|
||||||
|
backupReady: 'بکاپ آماده است — حالا میتوانی dump را دانلود کنی',
|
||||||
|
backupFailed: 'بکاپ ناموفق بود',
|
||||||
|
backupStarted: 'بکاپ آغاز شد — dump در حال انجام',
|
||||||
|
createFailed: 'ساخت بکاپ ناموفق بود',
|
||||||
|
backupDeleted: 'بکاپ حذف شد',
|
||||||
|
deleteFailed: 'حذف بکاپ ناموفق بود',
|
||||||
|
downloadStarted: 'دانلود آغاز شد',
|
||||||
|
downloadFailed: 'دانلود dump دیتابیس ناموفق بود',
|
||||||
|
deleteConfirmTitle: 'حذف بکاپ؟',
|
||||||
|
deleteConfirmMessage: 'حذف «{label}»؟ فایل dump برای همیشه حذف میشود.',
|
||||||
|
snapStatus: { completed: 'کامل', failed: 'ناموفق' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
@@ -801,6 +864,46 @@ const fa = {
|
|||||||
hours: 'ساعت',
|
hours: 'ساعت',
|
||||||
days: 'روز',
|
days: 'روز',
|
||||||
},
|
},
|
||||||
|
servicesNew: {
|
||||||
|
steps: ['نوع سرویس', 'پیکربندی', 'بررسی و پرداخت'],
|
||||||
|
title: 'سرویس مدیریتشدهٔ جدید',
|
||||||
|
subtitle: 'دیتابیس، Redis یا RabbitMQ — مثل اپلیکیشنها صورتحساب میشود',
|
||||||
|
typeDatabase: 'دیتابیس',
|
||||||
|
typeDatabaseDesc: 'PostgreSQL، MySQL، MariaDB، MongoDB',
|
||||||
|
typeRedis: 'Redis',
|
||||||
|
typeRedisDesc: 'کش و ذخیرهٔ درونحافظهای',
|
||||||
|
typeRabbitmq: 'RabbitMQ',
|
||||||
|
typeRabbitmqDesc: 'بروکر پیام',
|
||||||
|
serviceName: 'نام سرویس',
|
||||||
|
serviceNamePlaceholder: 'my-database',
|
||||||
|
nameHint: 'فقط حروف کوچک، اعداد و خط تیره',
|
||||||
|
redisVersion: 'نسخهٔ Redis',
|
||||||
|
rabbitmqVersion: 'نسخهٔ RabbitMQ',
|
||||||
|
redisResources: 'منابع Redis',
|
||||||
|
rabbitmqResources: 'منابع RabbitMQ',
|
||||||
|
billingCycle: 'دورهٔ صورتحساب',
|
||||||
|
cycles: { hourly: 'ساعتی', monthly: 'ماهانه', yearly: 'سالانه' },
|
||||||
|
paymentMethod: 'روش پرداخت',
|
||||||
|
wallet: 'کیفپول',
|
||||||
|
insufficient: 'ناکافی',
|
||||||
|
payNow: 'پرداخت',
|
||||||
|
pricingUnavailable: 'قیمت در دسترس نیست',
|
||||||
|
back: 'بازگشت',
|
||||||
|
next: 'بعدی',
|
||||||
|
payProvision: 'پرداخت و راهاندازی',
|
||||||
|
provisioning: 'در حال راهاندازی سرویس',
|
||||||
|
creatingService: 'ساخت سرویس',
|
||||||
|
uploadingDump: 'آپلود dump دیتابیس',
|
||||||
|
payment: 'پرداخت',
|
||||||
|
deploying: 'انتشار',
|
||||||
|
provisionedSuccess: 'سرویس با موفقیت راهاندازی شد',
|
||||||
|
deployFailed: 'انتشار ناموفق بود',
|
||||||
|
paySucceededDeployFailed: 'پرداخت موفق بود اما انتشار ناموفق شد',
|
||||||
|
payProvisionFailed: 'پرداخت یا راهاندازی ناموفق بود',
|
||||||
|
payFailed: 'پرداخت ناموفق بود',
|
||||||
|
insufficientBalance: 'موجودی کیفپول کافی نیست',
|
||||||
|
serviceDesc: 'سرویس: {name} ({cycle})',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user