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'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
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 } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types'; import type { Application, Invoice, OptionalServiceResourcesMap, ResourceUsage } from '@/types';
import { import {
ResourceUpgradeConfirmModal, ResourceUpgradeConfirmModal,
@@ -88,7 +89,9 @@ export function ManagedServiceResourcesPanel({
isStopped: boolean; isStopped: boolean;
needsRenewal?: boolean; needsRenewal?: boolean;
}) { }) {
const router = useRouter(); const t = useT();
const sr = t.components.serviceResources;
const router = useLocalizedRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dbFileInputRef = useRef<HTMLInputElement>(null); const dbFileInputRef = useRef<HTMLInputElement>(null);
const [showResources, setShowResources] = useState(false); const [showResources, setShowResources] = useState(false);
@@ -164,11 +167,11 @@ export function ManagedServiceResourcesPanel({
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['application', serviceId] }); queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['resources', serviceId] }); queryClient.invalidateQueries({ queryKey: ['resources', serviceId] });
toast.success('Resources updated'); toast.success(sr.resourcesUpdated);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
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 || 'Failed to update resources'); toast.error(msg || sr.updateFailed);
}, },
}); });
@@ -185,14 +188,14 @@ export function ManagedServiceResourcesPanel({
setPendingUpgradePayload(null); setPendingUpgradePayload(null);
const paidAmount = res.data.paidAmount || 0; const paidAmount = res.data.paidAmount || 0;
if (paidAmount > 0) { if (paidAmount > 0) {
toast.success(`Resources upgraded! Paid ${paidAmount.toLocaleString()} Toman`); toast.success(sr.upgradedPaid.replace('{amount}', paidAmount.toLocaleString('en-US')));
} else { } else {
toast.success('Resources updated successfully'); toast.success(sr.updatedSuccess);
} }
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
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 || 'Failed to upgrade resources'); toast.error(msg || sr.upgradeFailed);
}, },
}); });
@@ -205,7 +208,7 @@ export function ManagedServiceResourcesPanel({
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
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 || 'Failed to calculate upgrade cost'); toast.error(msg || sr.calcFailed);
}, },
}); });
@@ -213,7 +216,7 @@ export function ManagedServiceResourcesPanel({
mutationFn: (data: UpgradePayload) => mutationFn: (data: UpgradePayload) =>
api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data), api.post<Invoice>(`/billing/applications/${serviceId}/upgrade/invoice`, data).then((r) => r.data),
onSuccess: (invoice) => { onSuccess: (invoice) => {
toast.success('Invoice created. Choose how you want to pay.'); toast.success(sr.invoiceCreated);
queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['invoices'] });
setShowUpgradeConfirm(false); setShowUpgradeConfirm(false);
setUpgradeCostData(null); setUpgradeCostData(null);
@@ -222,7 +225,7 @@ export function ManagedServiceResourcesPanel({
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
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 || 'Failed to create upgrade invoice'); toast.error(msg || sr.invoiceFailed);
}, },
}); });
@@ -237,12 +240,12 @@ export function ManagedServiceResourcesPanel({
onSuccess: (res) => { onSuccess: (res) => {
const data = res.data; const data = res.data;
setDbRestoreLogs(data.logs || null); setDbRestoreLogs(data.logs || null);
if (data.success) toast.success('Database restored successfully'); if (data.success) toast.success(sr.restoredSuccess);
else toast.error(data.message || 'Database restore failed'); else toast.error(data.message || sr.restoreFailed);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
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 || 'Failed to upload database dump'); toast.error(msg || sr.uploadFailed);
setDbRestoreLogs(null); setDbRestoreLogs(null);
}, },
}); });
@@ -250,17 +253,17 @@ export function ManagedServiceResourcesPanel({
const handleDbFileUpload = useCallback( const handleDbFileUpload = useCallback(
(file: File) => { (file: File) => {
if (!file.name.endsWith('.sql') && !file.name.endsWith('.gz') && !file.name.endsWith('.dump')) { 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; return;
} }
if (file.size > 500 * 1024 * 1024) { if (file.size > 500 * 1024 * 1024) {
toast.error('File size must be less than 500MB'); toast.error(sr.fileTooLarge);
return; return;
} }
setDbRestoreLogs(null); setDbRestoreLogs(null);
dbUploadMutation.mutate(file); dbUploadMutation.mutate(file);
}, },
[dbUploadMutation], [dbUploadMutation, sr],
); );
const currentDbGi = const currentDbGi =
@@ -297,7 +300,7 @@ export function ManagedServiceResourcesPanel({
const applyResources = () => { const applyResources = () => {
if (needsRenewal) { if (needsRenewal) {
toast.warn('Renew the service before changing resources'); toast.warn(sr.renewFirst);
return; return;
} }
@@ -374,16 +377,16 @@ export function ManagedServiceResourcesPanel({
<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">
<BarChart3 className="w-5 h-5" /> Resources &amp; Scaling <BarChart3 className="w-5 h-5" /> {sr.title}
</h2> </h2>
<button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm"> <button type="button" onClick={() => setShowResources(!showResources)} className="btn-secondary text-sm">
{showResources ? ( {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> </button>
@@ -392,28 +395,28 @@ export function ManagedServiceResourcesPanel({
{showResources && ( {showResources && (
<div className="space-y-6"> <div className="space-y-6">
{!isDeployed ? ( {!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 ? ( ) : 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 ? ( ) : metricsWorkload ? (
<div className="border border-gray-200 rounded-xl p-4 space-y-4 bg-slate-50/50"> <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"> <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> <span className="text-[11px] text-gray-400 font-mono">{metricsWorkload.deploymentName}</span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-center"> <div className="grid grid-cols-3 gap-2 text-center">
<div className="bg-blue-50 rounded-lg p-3"> <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"> <p className="text-lg font-bold text-blue-800">
{metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas} {metricsWorkload.configured.readyReplicas}/{metricsWorkload.configured.replicas}
</p> </p>
</div> </div>
<div className="bg-green-50 rounded-lg p-3"> <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> <p className="text-lg font-bold text-green-800">{metricsWorkload.pods.length}</p>
</div> </div>
<div className="bg-purple-50 rounded-lg p-3"> <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"> <p className="text-lg font-bold text-purple-800 flex justify-center">
{metricsWorkload.metrics.length > 0 ? ( {metricsWorkload.metrics.length > 0 ? (
<CheckCircle className="w-5 h-5 text-purple-700" /> <CheckCircle className="w-5 h-5 text-purple-700" />
@@ -469,21 +472,21 @@ export function ManagedServiceResourcesPanel({
</div> </div>
) : ( ) : (
<p className="text-sm text-gray-400 text-center py-4"> <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> </p>
)} )}
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"> <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> </h3>
{storageUsageLoading ? ( {storageUsageLoading ? (
<p className="text-sm text-gray-400">Loading storage</p> <p className="text-sm text-gray-400">{sr.loadingStorage}</p>
) : storageSlice ? ( ) : storageSlice ? (
<div className="bg-gray-50 rounded-xl p-4 space-y-3"> <div className="bg-gray-50 rounded-xl p-4 space-y-3">
<div className="flex justify-between text-xs text-gray-600"> <div className="flex justify-between text-xs text-gray-600">
<span>Used {storageSlice.usedGi.toFixed(2)} GiB</span> <span>{sr.used} {storageSlice.usedGi.toFixed(2)} GiB</span>
<span>Allocated {storageSlice.allocatedGi.toFixed(1)} GiB</span> <span>{sr.allocated} {storageSlice.allocatedGi.toFixed(1)} GiB</span>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-3"> <div className="w-full bg-gray-200 rounded-full h-3">
<div <div
@@ -527,29 +530,27 @@ export function ManagedServiceResourcesPanel({
</div> </div>
<span className="text-sm text-gray-600">GB</span> <span className="text-sm text-gray-600">GB</span>
<p className="text-xs text-gray-400 w-full"> <p className="text-xs text-gray-400 w-full">
Only expansion is allowed. Use Apply changes below. {sr.expansionNote}
{app.billingCycle {app.billingCycle ? sr.storageChargedNote : ''}
? ' Additional storage is charged for the remaining billing period.'
: ''}
</p> </p>
</div> </div>
)} )}
</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>
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"> <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> </h3>
{app.productType === 'managed_database' && ( {app.productType === 'managed_database' && (
<DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} /> <DatabaseWorkloadResources values={dbResources} onChange={(p) => setDbResources((v) => ({ ...v, ...p }))} />
)} )}
{app.productType === 'managed_redis' && ( {app.productType === 'managed_redis' && (
<OptionalServiceResourceFields <OptionalServiceResourceFields
title="Redis resources" title={sr.redisResources}
accentClass="text-red-500" accentClass="text-red-500"
borderClass="border-red-200" borderClass="border-red-200"
bgClass="bg-red-50/30" bgClass="bg-red-50/30"
@@ -559,7 +560,7 @@ export function ManagedServiceResourcesPanel({
)} )}
{app.productType === 'managed_rabbitmq' && ( {app.productType === 'managed_rabbitmq' && (
<OptionalServiceResourceFields <OptionalServiceResourceFields
title="RabbitMQ resources" title={sr.rabbitmqResources}
accentClass="text-orange-500" accentClass="text-orange-500"
borderClass="border-orange-200" borderClass="border-orange-200"
bgClass="bg-orange-50/30" bgClass="bg-orange-50/30"
@@ -568,10 +569,7 @@ export function ManagedServiceResourcesPanel({
/> />
)} )}
{app.billingCycle && ( {app.billingCycle && (
<p className="text-xs text-gray-500 mt-3"> <p className="text-xs text-gray-500 mt-3">{sr.upgradeChargeNote}</p>
Upgrades that increase cost are charged for the remaining billing period (wallet or invoice), same as
applications.
</p>
)} )}
<button <button
type="button" type="button"
@@ -581,11 +579,11 @@ export function ManagedServiceResourcesPanel({
> >
{resourcesPending ? ( {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> </button>
@@ -594,7 +592,7 @@ export function ManagedServiceResourcesPanel({
{isDatabase && ( {isDatabase && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1"> <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> </h3>
<div <div
onDrop={(e) => { onDrop={(e) => {
@@ -625,12 +623,12 @@ export function ManagedServiceResourcesPanel({
}} }}
/> />
{dbUploadMutation.isPending ? ( {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" /> <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-sm font-medium text-gray-700">{sr.uploadToRestore}</p>
<p className="text-xs text-gray-500 mt-1">.sql, .gz, or .dump max 500MB</p> <p className="text-xs text-gray-500 mt-1">{sr.uploadFormats}</p>
</> </>
)} )}
</div> </div>
+44
View File
@@ -315,6 +315,50 @@ const en: Dictionary = {
deleteConfirmMessage: 'Delete “{label}”? The dump file will be permanently removed.', deleteConfirmMessage: 'Delete “{label}”? The dump file will be permanently removed.',
snapStatus: { completed: 'completed', failed: 'failed' }, 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: { nav: {
+44
View File
@@ -314,6 +314,50 @@ const fa = {
deleteConfirmMessage: 'حذف «{label}»؟ فایل dump برای همیشه حذف می‌شود.', deleteConfirmMessage: 'حذف «{label}»؟ فایل dump برای همیشه حذف می‌شود.',
snapStatus: { completed: 'کامل', failed: 'ناموفق' }, 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: { nav: {
File diff suppressed because one or more lines are too long