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';
|
||||
|
||||
import { useRef, useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import type {
|
||||
Application,
|
||||
CreateApplicationDto,
|
||||
@@ -37,11 +38,12 @@ import {
|
||||
|
||||
type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
|
||||
|
||||
const steps = ['Service type', 'Configuration', 'Review & pay'];
|
||||
|
||||
export default function NewManagedServicePage() {
|
||||
const t = useT();
|
||||
const s = t.dashboard.servicesNew;
|
||||
const steps = s.steps;
|
||||
const { notifyDeployStarted } = useDeployProgressActions();
|
||||
const router = useRouter();
|
||||
const router = useLocalizedRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [kind, setKind] = useState<ServiceKind | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
|
||||
@@ -181,10 +183,10 @@ export default function NewManagedServicePage() {
|
||||
await api.post(`/deployments/applications/${appId}/deploy`);
|
||||
} catch {
|
||||
useDeployProgressStore.getState().stopTracking(appId);
|
||||
throw new Error('Deploy failed');
|
||||
throw new Error(s.deployFailed);
|
||||
}
|
||||
setDeployStage('done');
|
||||
toast.success('Service provisioned successfully');
|
||||
toast.success(s.provisionedSuccess);
|
||||
router.push(`/dashboard/services/${appId}`);
|
||||
};
|
||||
|
||||
@@ -217,12 +219,12 @@ export default function NewManagedServicePage() {
|
||||
onSuccess: (appId) =>
|
||||
finishDeploy(appId, form.name).catch(() => {
|
||||
setDeployStage('error');
|
||||
toast.error('Payment succeeded but deployment failed');
|
||||
toast.error(s.paySucceededDeployFailed);
|
||||
}),
|
||||
onError: (err: unknown) => {
|
||||
setDeployStage('error');
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -233,7 +235,7 @@ export default function NewManagedServicePage() {
|
||||
if (payAmount > 0) {
|
||||
const { data: gw } = await api.post('/billing/gateway/initiate', {
|
||||
amount: payAmount,
|
||||
description: `Service: ${form.name} (${selectedCycle})`,
|
||||
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
|
||||
callbackUrl: `${window.location.origin}/dashboard/services/new`,
|
||||
});
|
||||
await api.post('/billing/gateway/verify', {
|
||||
@@ -253,19 +255,19 @@ export default function NewManagedServicePage() {
|
||||
onSuccess: (appId) =>
|
||||
finishDeploy(appId, form.name).catch(() => {
|
||||
setDeployStage('error');
|
||||
toast.error('Payment succeeded but deployment failed');
|
||||
toast.error(s.paySucceededDeployFailed);
|
||||
}),
|
||||
onError: (err: unknown) => {
|
||||
setDeployStage('error');
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
const validateBeforePay = () => {
|
||||
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) {
|
||||
setRestoreStorageErrorMessage(err);
|
||||
setShowRestoreStorageErrorModal(true);
|
||||
@@ -280,7 +282,7 @@ export default function NewManagedServicePage() {
|
||||
if (payAmount === 0) walletPayMutation.mutate();
|
||||
else if (paymentMethod === 'wallet') {
|
||||
if (!hasEnoughBalance) {
|
||||
toast.error('Insufficient wallet balance');
|
||||
toast.error(s.insufficientBalance);
|
||||
return;
|
||||
}
|
||||
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="flex items-center gap-3">
|
||||
<Link href="/dashboard/services" className="btn-ghost">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<ArrowLeft className="w-4 h-4 rtl:rotate-180" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="page-title">New managed service</h1>
|
||||
<p className="page-subtitle">Database, Redis, or RabbitMQ — billed like applications</p>
|
||||
<h1 className="page-title">{s.title}</h1>
|
||||
<p className="page-subtitle">{s.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,9 +340,9 @@ export default function NewManagedServicePage() {
|
||||
<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_redis' as const, title: 'Redis', desc: 'In-memory cache & store' },
|
||||
{ id: 'managed_rabbitmq' as const, title: 'RabbitMQ', desc: 'Message broker' },
|
||||
{ id: 'managed_database' as const, title: s.typeDatabase, desc: s.typeDatabaseDesc },
|
||||
{ id: 'managed_redis' as const, title: s.typeRedis, desc: s.typeRedisDesc },
|
||||
{ id: 'managed_rabbitmq' as const, title: s.typeRabbitmq, desc: s.typeRabbitmqDesc },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<button
|
||||
@@ -380,14 +382,14 @@ export default function NewManagedServicePage() {
|
||||
{step === 1 && kind && (
|
||||
<div className="space-y-6">
|
||||
<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
|
||||
className="input-field"
|
||||
value={form.name}
|
||||
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>
|
||||
|
||||
{kind === 'managed_database' && (
|
||||
@@ -413,7 +415,7 @@ export default function NewManagedServicePage() {
|
||||
{kind === 'managed_redis' && (
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
className="input-field max-w-xs"
|
||||
value={form.redisVersion}
|
||||
@@ -428,7 +430,7 @@ export default function NewManagedServicePage() {
|
||||
</div>
|
||||
{form.optionalServiceResources?.redis && (
|
||||
<OptionalServiceResourceFields
|
||||
title="Redis resources"
|
||||
title={s.redisResources}
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-400"
|
||||
bgClass="bg-red-50"
|
||||
@@ -450,7 +452,7 @@ export default function NewManagedServicePage() {
|
||||
{kind === 'managed_rabbitmq' && (
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
className="input-field max-w-xs"
|
||||
value={form.rabbitmqVersion}
|
||||
@@ -465,7 +467,7 @@ export default function NewManagedServicePage() {
|
||||
</div>
|
||||
{form.optionalServiceResources?.rabbitmq && (
|
||||
<OptionalServiceResourceFields
|
||||
title="RabbitMQ resources"
|
||||
title={s.rabbitmqResources}
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-400"
|
||||
bgClass="bg-orange-50"
|
||||
@@ -495,20 +497,20 @@ export default function NewManagedServicePage() {
|
||||
) : costData ? (
|
||||
<>
|
||||
<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">
|
||||
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
|
||||
<button
|
||||
key={cycle}
|
||||
type="button"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{cycle}
|
||||
{s.cycles[cycle]}
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
@@ -516,7 +518,7 @@ export default function NewManagedServicePage() {
|
||||
</div>
|
||||
{requiresPayment && (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -526,39 +528,39 @@ export default function NewManagedServicePage() {
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{Number(walletBalance).toLocaleString('en-US')} T
|
||||
{!hasEnoughBalance && <span className="text-red-500 block">Insufficient</span>}
|
||||
{Number(walletBalance).toLocaleString('en-US')} {t.common.currencyShort}
|
||||
{!hasEnoughBalance && <span className="text-red-500 block">{s.insufficient}</span>}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</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 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">
|
||||
← Back
|
||||
<span className="rtl:hidden">← </span>{s.back}<span className="ltr:hidden"> →</span>
|
||||
</button>
|
||||
{step < 2 ? (
|
||||
<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
|
||||
@@ -570,7 +572,7 @@ export default function NewManagedServicePage() {
|
||||
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin inline" />
|
||||
) : (
|
||||
'Pay & provision'
|
||||
s.payProvision
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -580,7 +582,7 @@ export default function NewManagedServicePage() {
|
||||
{deployStage !== 'idle' && (
|
||||
<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">
|
||||
<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="flex items-center gap-2">
|
||||
{deployStage === 'creating' ? (
|
||||
@@ -588,7 +590,7 @@ export default function NewManagedServicePage() {
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
Creating service
|
||||
{s.creatingService}
|
||||
</div>
|
||||
{dbDumpFile && kind === 'managed_database' && (
|
||||
<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" />
|
||||
)}
|
||||
Uploading database dump
|
||||
{s.uploadingDump}
|
||||
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
|
||||
<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" />
|
||||
)}
|
||||
Payment
|
||||
{s.payment}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{deployStage === 'deploying' ? (
|
||||
@@ -632,7 +634,7 @@ export default function NewManagedServicePage() {
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
|
||||
)}
|
||||
Deploying
|
||||
{s.deploying}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { AppSnapshot } from '@/types';
|
||||
import { formatBytes } from '@/lib/format-utils';
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ function BackupProgressBar({ progress, label }: { progress: number; label?: stri
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<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>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
|
||||
@@ -54,6 +55,10 @@ export function DatabaseSnapshotsPanel({
|
||||
serviceId: string;
|
||||
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 confirm = useConfirm();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
@@ -82,10 +87,10 @@ export function DatabaseSnapshotsPanel({
|
||||
snap.status === 'completed' &&
|
||||
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') {
|
||||
toast.error(snap.errorMessage || 'Backup failed');
|
||||
toast.error(snap.errorMessage || sn.backupFailed);
|
||||
}
|
||||
}
|
||||
prevInProgressRef.current = inProgressIds;
|
||||
@@ -94,18 +99,18 @@ export function DatabaseSnapshotsPanel({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(
|
||||
`/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`,
|
||||
`/snapshots/applications/${serviceId}?label=${encodeURIComponent(sn.defaultLabel)}`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowPanel(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||
toast.info('Backup started — dump in progress');
|
||||
toast.info(sn.backupStarted);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
||||
?.message;
|
||||
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: () => {
|
||||
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) => {
|
||||
@@ -139,16 +144,16 @@ export function DatabaseSnapshotsPanel({
|
||||
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
||||
link.click();
|
||||
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 ok = await confirm({
|
||||
title: 'Delete backup?',
|
||||
message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`,
|
||||
confirmText: 'Delete',
|
||||
title: sn.deleteConfirmTitle,
|
||||
message: sn.deleteConfirmMessage.replace('{label}', snap.label || sn.defaultLabel),
|
||||
confirmText: t.common.delete,
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(snap.id);
|
||||
@@ -163,7 +168,7 @@ export function DatabaseSnapshotsPanel({
|
||||
<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">
|
||||
<History className="w-5 h-5" /> Snapshots
|
||||
<History className="w-5 h-5" /> {sn.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -173,30 +178,30 @@ export function DatabaseSnapshotsPanel({
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
title={
|
||||
!isDeployed
|
||||
? 'Deploy the service first'
|
||||
? sn.deployFirst
|
||||
: hasInProgress
|
||||
? 'Wait for the current backup to finish'
|
||||
? sn.waitCurrent
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{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 type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
|
||||
{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>
|
||||
@@ -207,19 +212,17 @@ export function DatabaseSnapshotsPanel({
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> 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.
|
||||
<Camera className="w-3 h-3 inline" /> {sn.infoNote}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<div className="text-center py-8">
|
||||
<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-400 text-xs mt-1">Click New backup to create your first database dump.</p>
|
||||
<p className="text-gray-500 text-sm">{sn.noBackups}</p>
|
||||
<p className="text-gray-400 text-xs mt-1">{sn.noBackupsHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<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-1 min-w-0">
|
||||
<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
|
||||
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' ? 'Auto' : 'Manual'}
|
||||
{snap.type === 'pre_deploy' ? sn.auto : sn.manual}
|
||||
</span>
|
||||
<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>
|
||||
</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' && (
|
||||
<BackupProgressBar
|
||||
progress={snap.progress ?? 0}
|
||||
label={
|
||||
(snap.progress ?? 0) < 15
|
||||
? 'Preparing dump…'
|
||||
? sn.preparing
|
||||
: (snap.progress ?? 0) < 90
|
||||
? 'Exporting database…'
|
||||
: 'Finalizing…'
|
||||
? sn.exporting
|
||||
: sn.finalizing
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snap.status === 'completed' && snap.dbDumpPath && (
|
||||
<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>
|
||||
)}
|
||||
{snap.errorMessage && (
|
||||
@@ -281,7 +284,7 @@ export function DatabaseSnapshotsPanel({
|
||||
type="button"
|
||||
onClick={() => downloadSnapshotDb(snap.id)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -290,7 +293,7 @@ export function DatabaseSnapshotsPanel({
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deletingSnapshotId !== null}
|
||||
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 ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
@@ -306,7 +309,7 @@ export function DatabaseSnapshotsPanel({
|
||||
onClick={() => handleDelete(snap)}
|
||||
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"
|
||||
title="Remove failed backup"
|
||||
title={sn.removeFailedTitle}
|
||||
>
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
@@ -321,7 +324,7 @@ export function DatabaseSnapshotsPanel({
|
||||
</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>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||||
|
||||
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
||||
@@ -45,6 +46,7 @@ export function ManagedDatabaseConfig({
|
||||
dbDumpFile: File | null;
|
||||
onDbDumpFileChange: (file: File | null) => void;
|
||||
}) {
|
||||
const c = useT().components.dbConfig;
|
||||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -54,11 +56,11 @@ export function ManagedDatabaseConfig({
|
||||
|
||||
const acceptDump = (f: File) => {
|
||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||
toast.error('Allowed: .sql, .gz, .dump');
|
||||
toast.error(c.allowedFormats);
|
||||
return;
|
||||
}
|
||||
if (f.size > 500 * 1024 * 1024) {
|
||||
toast.error('Max 500MB');
|
||||
toast.error(c.maxSize);
|
||||
return;
|
||||
}
|
||||
onDbDumpFileChange(f);
|
||||
@@ -70,7 +72,7 @@ export function ManagedDatabaseConfig({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<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">
|
||||
{DB_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -98,7 +100,7 @@ export function ManagedDatabaseConfig({
|
||||
</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
|
||||
className="input-field max-w-xs"
|
||||
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="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Database credentials</h3>
|
||||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</span>
|
||||
<h3 className="text-sm font-semibold text-gray-800">{c.credentials}</h3>
|
||||
<span className="text-xs text-gray-400">{c.credentialsOptional}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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
|
||||
className="input-field"
|
||||
placeholder="appuser"
|
||||
placeholder={c.usernamePlaceholder}
|
||||
value={form.dbUsername}
|
||||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||||
/>
|
||||
</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">
|
||||
<input
|
||||
className="input-field pr-20"
|
||||
className="input-field pr-20 rtl:pr-3 rtl:pl-20"
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
placeholder="Auto-generated"
|
||||
placeholder={c.passwordPlaceholder}
|
||||
value={form.dbPassword}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -149,7 +151,7 @@ export function ManagedDatabaseConfig({
|
||||
setShowDbPassword(true);
|
||||
}}
|
||||
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" />
|
||||
</button>
|
||||
@@ -164,12 +166,10 @@ export function ManagedDatabaseConfig({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Used for internal cluster access. Use temporary or permanent external access below after deploy.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{c.credentialsNote}</p>
|
||||
|
||||
<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
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -204,7 +204,7 @@ export function ManagedDatabaseConfig({
|
||||
/>
|
||||
{dbDumpFile ? (
|
||||
<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="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||||
</div>
|
||||
@@ -216,20 +216,20 @@ export function ManagedDatabaseConfig({
|
||||
}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
Remove
|
||||
{c.remove}
|
||||
</button>
|
||||
</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-xs text-gray-400">Optional • Max 500MB • .sql, .gz, .dump</p>
|
||||
<p className="text-sm text-gray-700">{c.uploadHint}</p>
|
||||
<p className="text-xs text-gray-400">{c.uploadConstraints}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
@@ -274,13 +274,13 @@ export function ManagedDatabaseConfig({
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{dbDumpFile && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Minimum {minDbGiFromRestoreDump} GB
|
||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation
|
||||
{c.minimumGb.replace('{n}', String(minDbGiFromRestoreDump))}
|
||||
{dbDumpFile ? c.mustFitDump : ''}{c.expansionOnly}
|
||||
</p>
|
||||
</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(
|
||||
dbDumpFile: File | null,
|
||||
dbStorageSizeGi: number,
|
||||
detailTemplate: string,
|
||||
): string | null {
|
||||
if (!dbDumpFile) return null;
|
||||
const need = minGiToFitFileBytes(dbDumpFile.size);
|
||||
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;
|
||||
}
|
||||
@@ -309,6 +317,7 @@ export function RestoreStorageErrorModal({
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const c = useT().components.dbConfig;
|
||||
if (!open) return null;
|
||||
return (
|
||||
<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">
|
||||
<AlertCircle className="w-6 h-6 text-red-600" />
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<button type="button" onClick={onClose} className="btn-primary">
|
||||
OK
|
||||
{c.ok}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -252,6 +252,69 @@ const en: Dictionary = {
|
||||
mShort: 'm',
|
||||
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: {
|
||||
@@ -802,6 +865,46 @@ const en: Dictionary = {
|
||||
hours: 'hours',
|
||||
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: 'د',
|
||||
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: {
|
||||
@@ -801,6 +864,46 @@ const fa = {
|
||||
hours: 'ساعت',
|
||||
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