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:
keyhan
2026-06-11 21:02:57 +03:30
parent 96d24b50a1
commit deef498f65
4 changed files with 136 additions and 50 deletions
@@ -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 &amp; 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>
+44
View File
@@ -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: {
+44
View File
@@ -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: {