Localize the managed-service resources panel.
Add components.serviceResources and move the resources/scaling panel (metrics, storage usage/expansion, CPU/memory adjust, dump restore, upgrade toasts) onto it; route through the locale-aware router. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
|
||||
import {
|
||||
ResourceUpgradeConfirmModal,
|
||||
@@ -88,7 +89,9 @@ export function ManagedServiceResourcesPanel({
|
||||
isStopped: boolean;
|
||||
needsRenewal?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const sr = t.components.serviceResources;
|
||||
const router = useLocalizedRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const dbFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showResources, setShowResources] = useState(false);
|
||||
@@ -164,11 +167,11 @@ export function ManagedServiceResourcesPanel({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
|
||||
toast.success('Resources updated');
|
||||
toast.success(sr.resourcesUpdated);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to update resources');
|
||||
toast.error(msg || sr.updateFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -185,14 +188,14 @@ export function ManagedServiceResourcesPanel({
|
||||
setPendingUpgradePayload(null);
|
||||
const paidAmount = res.data.paidAmount || 0;
|
||||
if (paidAmount > 0) {
|
||||
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`);
|
||||
toast.success(sr.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US')));
|
||||
} else {
|
||||
toast.success('Resources updated successfully');
|
||||
toast.success(sr.updatedSuccess);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to upgrade resources');
|
||||
toast.error(msg || sr.upgradeFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -205,7 +208,7 @@ export function ManagedServiceResourcesPanel({
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to calculate upgrade cost');
|
||||
toast.error(msg || sr.calcFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,7 +216,7 @@ export function ManagedServiceResourcesPanel({
|
||||
mutationFn: (data: UpgradePayload) =>
|
||||
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
|
||||
onSuccess: (invoice) => {
|
||||
toast.success('Invoice created. Choose how you want to pay.');
|
||||
toast.success(sr.invoiceCreated);
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setShowUpgradeConfirm(false);
|
||||
setUpgradeCostData(null);
|
||||
@@ -222,7 +225,7 @@ export function ManagedServiceResourcesPanel({
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to create upgrade invoice');
|
||||
toast.error(msg || sr.invoiceFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -237,12 +240,12 @@ export function ManagedServiceResourcesPanel({
|
||||
onSuccess: (res) => {
|
||||
const data = res.data;
|
||||
setDbRestoreLogs(data.logs || null);
|
||||
if (data.success) toast.success('Database restored successfully');
|
||||
else toast.error(data.message || 'Database restore failed');
|
||||
if (data.success) toast.success(sr.restoredSuccess);
|
||||
else toast.error(data.message || sr.restoreFailed);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Failed to upload database dump');
|
||||
toast.error(msg || sr.uploadFailed);
|
||||
setDbRestoreLogs(null);
|
||||
},
|
||||
});
|
||||
@@ -250,17 +253,17 @@ export function ManagedServiceResourcesPanel({
|
||||
const handleDbFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) {
|
||||
toast.error('Please upload a .sql, .dump, or .gz file');
|
||||
toast.error(sr.invalidFile);
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
toast.error('File size must be less than 500MB');
|
||||
toast.error(sr.fileTooLarge);
|
||||
return;
|
||||
}
|
||||
setDbRestoreLogs(null);
|
||||
dbUploadMutation.mutate(file);
|
||||
},
|
||||
[dbUploadMutation],
|
||||
[dbUploadMutation, sr],
|
||||
);
|
||||
|
||||
const currentDbGi =
|
||||
@@ -297,7 +300,7 @@ export function ManagedServiceResourcesPanel({
|
||||
|
||||
const applyResources = () => {
|
||||
if (needsRenewal) {
|
||||
toast.warn('Renew the service before changing resources');
|
||||
toast.warn(sr.renewFirst);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -374,16 +377,16 @@ export function ManagedServiceResourcesPanel({
|
||||
<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">
|
||||
<BarChart3 className="w-5 h-5" /> Resources & Scaling
|
||||
<BarChart3 className="w-5 h-5" /> {sr.title}
|
||||
</h2>
|
||||
<button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm">
|
||||
{showResources ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 inline" /> Hide
|
||||
<ChevronDown className="w-4 h-4 inline" /> {sr.hide}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BarChart3 className="w-4 h-4 inline" /> Monitor
|
||||
<BarChart3 className="w-4 h-4 inline" /> {sr.monitor}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -392,28 +395,28 @@ export function ManagedServiceResourcesPanel({
|
||||
{showResources && (
|
||||
<div className="space-y-6">
|
||||
{!isDeployed ? (
|
||||
<p className="text-sm text-gray-500 text-center py-6">Deploy the service to view metrics and adjust resources.</p>
|
||||
<p className="text-sm text-gray-500 text-center py-6">{sr.deployToView}</p>
|
||||
) : resourcesLoading ? (
|
||||
<div className="text-center py-6 text-gray-400 text-sm">Loading metrics…</div>
|
||||
<div className="text-center py-6 text-gray-400 text-sm">{sr.loadingMetrics}</div>
|
||||
) : metricsWorkload ? (
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{metricsWorkload.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-gray-800">{(sr.workloads as Record<string, string>)[workloadKey(app)] ?? metricsWorkload.title}</h3>
|
||||
<span className="text-[11px] text-gray-400 font-mono">{metricsWorkload.deploymentName}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-blue-600 font-medium">Replicas</p>
|
||||
<p className="text-[10px] text-blue-600 font-medium">{sr.replicas}</p>
|
||||
<p className="text-lg font-bold text-blue-800">
|
||||
{metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-green-600 font-medium">Pods</p>
|
||||
<p className="text-[10px] text-green-600 font-medium">{sr.pods}</p>
|
||||
<p className="text-lg font-bold text-green-800">{metricsWorkload.pods.length}</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3">
|
||||
<p className="text-[10px] text-purple-600 font-medium">Metrics</p>
|
||||
<p className="text-[10px] text-purple-600 font-medium">{sr.metrics}</p>
|
||||
<p className="text-lg font-bold text-purple-800 flex justify-center">
|
||||
{metricsWorkload.metrics.length > 0 ? (
|
||||
<CheckCircle className="w-5 h-5 text-purple-700" />
|
||||
@@ -469,21 +472,21 @@ export function ManagedServiceResourcesPanel({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
{isStopped ? 'Service is stopped.' : 'No resource metrics yet.'}
|
||||
{isStopped ? sr.serviceStopped : sr.noMetrics}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Database className="w-4 h-4" /> Storage
|
||||
<Database className="w-4 h-4" /> {sr.storage}
|
||||
</h3>
|
||||
{storageUsageLoading ? (
|
||||
<p className="text-sm text-gray-400">Loading storage…</p>
|
||||
<p className="text-sm text-gray-400">{sr.loadingStorage}</p>
|
||||
) : storageSlice ? (
|
||||
<div className="bg-gray-50 rounded-xl p-4 space-y-3">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>Used {storageSlice.usedGi.toFixed(2)} GiB</span>
|
||||
<span>Allocated {storageSlice.allocatedGi.toFixed(1)} GiB</span>
|
||||
<span>{sr.used} {storageSlice.usedGi.toFixed(2)} GiB</span>
|
||||
<span>{sr.allocated} {storageSlice.allocatedGi.toFixed(1)} GiB</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
@@ -527,29 +530,27 @@ export function ManagedServiceResourcesPanel({
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">GB</span>
|
||||
<p className="text-xs text-gray-400 w-full">
|
||||
Only expansion is allowed. Use Apply changes below.
|
||||
{app.billingCycle
|
||||
? ' Additional storage is charged for the remaining billing period.'
|
||||
: ''}
|
||||
{sr.expansionNote}
|
||||
{app.billingCycle ? sr.storageChargedNote : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">Storage metrics unavailable.</p>
|
||||
<p className="text-sm text-gray-400">{sr.storageUnavailable}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Settings className="w-4 h-4" /> Adjust CPU / memory
|
||||
<Settings className="w-4 h-4" /> {sr.adjustCpuMem}
|
||||
</h3>
|
||||
{app.productType === 'managed_database' && (
|
||||
<DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} />
|
||||
)}
|
||||
{app.productType === 'managed_redis' && (
|
||||
<OptionalServiceResourceFields
|
||||
title="Redis resources"
|
||||
title={sr.redisResources}
|
||||
accentClass="text-red-500"
|
||||
borderClass="border-red-200"
|
||||
bgClass="bg-red-50/30"
|
||||
@@ -559,7 +560,7 @@ export function ManagedServiceResourcesPanel({
|
||||
)}
|
||||
{app.productType === 'managed_rabbitmq' && (
|
||||
<OptionalServiceResourceFields
|
||||
title="RabbitMQ resources"
|
||||
title={sr.rabbitmqResources}
|
||||
accentClass="text-orange-500"
|
||||
borderClass="border-orange-200"
|
||||
bgClass="bg-orange-50/30"
|
||||
@@ -568,10 +569,7 @@ export function ManagedServiceResourcesPanel({
|
||||
/>
|
||||
)}
|
||||
{app.billingCycle && (
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as
|
||||
applications.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-3">{sr.upgradeChargeNote}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
@@ -581,11 +579,11 @@ export function ManagedServiceResourcesPanel({
|
||||
>
|
||||
{resourcesPending ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> Applying…
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> {sr.applying}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-3 h-3 inline" /> Apply changes
|
||||
<RefreshCw className="w-3 h-3 inline" /> {sr.applyChanges}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -594,7 +592,7 @@ export function ManagedServiceResourcesPanel({
|
||||
{isDatabase && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1">
|
||||
<Scale className="w-4 h-4" /> Restore database dump
|
||||
<Scale className="w-4 h-4" /> {sr.restoreDump}
|
||||
</h3>
|
||||
<div
|
||||
onDrop={(e) => {
|
||||
@@ -625,12 +623,12 @@ export function ManagedServiceResourcesPanel({
|
||||
}}
|
||||
/>
|
||||
{dbUploadMutation.isPending ? (
|
||||
<p className="text-sm text-gray-700">Restoring database…</p>
|
||||
<p className="text-sm text-gray-700">{sr.restoring}</p>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm font-medium text-gray-700">Upload SQL dump to restore</p>
|
||||
<p className="text-xs text-gray-500 mt-1">.sql, .gz, or .dump — max 500MB</p>
|
||||
<p className="text-sm font-medium text-gray-700">{sr.uploadToRestore}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{sr.uploadFormats}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -315,6 +315,50 @@ const en: Dictionary = {
|
||||
deleteConfirmMessage: 'Delete “{label}”? The dump file will be permanently removed.',
|
||||
snapStatus: { completed: 'completed', failed: 'failed' },
|
||||
},
|
||||
serviceResources: {
|
||||
title: 'Resources & Scaling',
|
||||
hide: 'Hide',
|
||||
monitor: 'Monitor',
|
||||
deployToView: 'Deploy the service to view metrics and adjust resources.',
|
||||
loadingMetrics: 'Loading metrics…',
|
||||
replicas: 'Replicas',
|
||||
pods: 'Pods',
|
||||
metrics: 'Metrics',
|
||||
serviceStopped: 'Service is stopped.',
|
||||
noMetrics: 'No resource metrics yet.',
|
||||
storage: 'Storage',
|
||||
loadingStorage: 'Loading storage…',
|
||||
used: 'Used',
|
||||
allocated: 'Allocated',
|
||||
expansionNote: 'Only expansion is allowed. Use Apply changes below.',
|
||||
storageChargedNote: ' Additional storage is charged for the remaining billing period.',
|
||||
storageUnavailable: 'Storage metrics unavailable.',
|
||||
adjustCpuMem: 'Adjust CPU / memory',
|
||||
redisResources: 'Redis resources',
|
||||
rabbitmqResources: 'RabbitMQ resources',
|
||||
upgradeChargeNote: 'Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as applications.',
|
||||
applying: 'Applying…',
|
||||
applyChanges: 'Apply changes',
|
||||
restoreDump: 'Restore database dump',
|
||||
restoring: 'Restoring database…',
|
||||
uploadToRestore: 'Upload SQL dump to restore',
|
||||
uploadFormats: '.sql, .gz, or .dump — max 500MB',
|
||||
workloads: { database: 'Database', redis: 'Redis', rabbitmq: 'RabbitMQ' },
|
||||
resourcesUpdated: 'Resources updated',
|
||||
updateFailed: 'Failed to update resources',
|
||||
upgradedPaid: 'Resources upgraded! Paid {amount} Toman',
|
||||
updatedSuccess: 'Resources updated successfully',
|
||||
upgradeFailed: 'Failed to upgrade resources',
|
||||
calcFailed: 'Failed to calculate upgrade cost',
|
||||
invoiceCreated: 'Invoice created. Choose how you want to pay.',
|
||||
invoiceFailed: 'Failed to create upgrade invoice',
|
||||
restoredSuccess: 'Database restored successfully',
|
||||
restoreFailed: 'Database restore failed',
|
||||
uploadFailed: 'Failed to upload database dump',
|
||||
invalidFile: 'Please upload a .sql, .dump, or .gz file',
|
||||
fileTooLarge: 'File size must be less than 500MB',
|
||||
renewFirst: 'Renew the service before changing resources',
|
||||
},
|
||||
},
|
||||
|
||||
nav: {
|
||||
|
||||
@@ -314,6 +314,50 @@ const fa = {
|
||||
deleteConfirmMessage: 'حذف «{label}»؟ فایل dump برای همیشه حذف میشود.',
|
||||
snapStatus: { completed: 'کامل', failed: 'ناموفق' },
|
||||
},
|
||||
serviceResources: {
|
||||
title: 'منابع و مقیاسبندی',
|
||||
hide: 'پنهان',
|
||||
monitor: 'پایش',
|
||||
deployToView: 'برای دیدن متریکها و تنظیم منابع، سرویس را منتشر کن.',
|
||||
loadingMetrics: 'در حال بارگذاری متریکها…',
|
||||
replicas: 'رپلیکاها',
|
||||
pods: 'پادها',
|
||||
metrics: 'متریکها',
|
||||
serviceStopped: 'سرویس متوقف است.',
|
||||
noMetrics: 'هنوز متریک منابعی نیست.',
|
||||
storage: 'فضای ذخیره',
|
||||
loadingStorage: 'در حال بارگذاری فضای ذخیره…',
|
||||
used: 'مصرفشده',
|
||||
allocated: 'تخصیصیافته',
|
||||
expansionNote: 'فقط افزایش مجاز است. از «اعمال تغییرات» زیر استفاده کن.',
|
||||
storageChargedNote: ' فضای ذخیرهٔ اضافه برای بازهٔ باقیماندهٔ صورتحساب محاسبه میشود.',
|
||||
storageUnavailable: 'متریک فضای ذخیره در دسترس نیست.',
|
||||
adjustCpuMem: 'تنظیم CPU / حافظه',
|
||||
redisResources: 'منابع Redis',
|
||||
rabbitmqResources: 'منابع RabbitMQ',
|
||||
upgradeChargeNote: 'ارتقاهایی که هزینه را افزایش میدهند برای بازهٔ باقیماندهٔ صورتحساب (کیفپول یا فاکتور) محاسبه میشوند، مثل اپلیکیشنها.',
|
||||
applying: 'در حال اعمال…',
|
||||
applyChanges: 'اعمال تغییرات',
|
||||
restoreDump: 'بازیابی dump دیتابیس',
|
||||
restoring: 'در حال بازیابی دیتابیس…',
|
||||
uploadToRestore: 'آپلود SQL dump برای بازیابی',
|
||||
uploadFormats: '.sql، .gz یا .dump — حداکثر ۵۰۰ مگابایت',
|
||||
workloads: { database: 'دیتابیس', redis: 'Redis', rabbitmq: 'RabbitMQ' },
|
||||
resourcesUpdated: 'منابع بهروزرسانی شد',
|
||||
updateFailed: 'بهروزرسانی منابع ناموفق بود',
|
||||
upgradedPaid: 'منابع ارتقا یافت! {amount} تومان پرداخت شد',
|
||||
updatedSuccess: 'منابع با موفقیت بهروزرسانی شد',
|
||||
upgradeFailed: 'ارتقای منابع ناموفق بود',
|
||||
calcFailed: 'محاسبهٔ هزینهٔ ارتقا ناموفق بود',
|
||||
invoiceCreated: 'فاکتور ساخته شد. روش پرداخت را انتخاب کن.',
|
||||
invoiceFailed: 'ساخت فاکتور ارتقا ناموفق بود',
|
||||
restoredSuccess: 'دیتابیس با موفقیت بازیابی شد',
|
||||
restoreFailed: 'بازیابی دیتابیس ناموفق بود',
|
||||
uploadFailed: 'آپلود dump دیتابیس ناموفق بود',
|
||||
invalidFile: 'لطفاً فایل .sql، .dump یا .gz آپلود کن',
|
||||
fileTooLarge: 'حجم فایل باید کمتر از ۵۰۰ مگابایت باشد',
|
||||
renewFirst: 'پیش از تغییر منابع، سرویس را تمدید کن',
|
||||
},
|
||||
},
|
||||
|
||||
nav: {
|
||||
|
||||
Reference in New Issue
Block a user