diff --git a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx index e7d9868..e320d44 100644 --- a/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/billing/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '@/lib/api'; import { toast } from 'react-toastify'; +import { useT } from '@/i18n/I18nProvider'; import type { PricingCatalog, PricingRateRow, @@ -15,18 +16,6 @@ import type { } from '@/types'; import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react'; -const resourceLabels: Record = { - base_fee: 'Base fee', - cpu_per_core: 'CPU (per core)', - memory_per_gb: 'Memory (per GB)', - storage_per_gb: 'Storage (per GB)', - database_addon: 'Database addon', - redis_addon: 'Redis', - rabbitmq_addon: 'RabbitMQ', - elasticsearch_addon: 'Elasticsearch', - custom_domain_addon: 'Custom domain + SSL', -}; - const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly']; /** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */ @@ -89,15 +78,17 @@ function PricingMatrixTable({ onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void; readOnly: boolean; }) { + const t = useT(); + const b = t.dashboard.billing; return (
- + {cycles.map((cycle) => ( - ))} @@ -106,7 +97,7 @@ function PricingMatrixTable({ {rows.map((row) => ( {cycles.map((cycle) => { const field = @@ -159,17 +150,16 @@ function DeployDefaultsFields({ readOnly: boolean; onUpdate: (patch: Partial) => void; }) { + const b = useT().dashboard.billing; const isLogging = service === 'elasticsearch'; if (isLogging) { return (
-

- Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on). -

+

{b.loggingNote}

- + {readOnly ? (

{profile.logShipperCpuLimit || '—'}

) : ( @@ -182,7 +172,7 @@ function DeployDefaultsFields({ )}
- + {readOnly ? (

{profile.logShipperMemoryLimit || '—'}

) : ( @@ -201,13 +191,10 @@ function DeployDefaultsFields({ return (
-

- Shown when a user enables this service in deploy. They can change CPU, memory, and storage in - Resources & Configuration; actual billing uses their choices × unit prices below. -

+

{b.defaultsNote}

- + {readOnly ? (

{profile.cpuRequest || '—'}

) : ( @@ -224,7 +211,7 @@ function DeployDefaultsFields({ )}
- + {readOnly ? (

{profile.cpuLimit}

) : ( @@ -236,13 +223,13 @@ function DeployDefaultsFields({ - - + + )}
- + {readOnly ? (

{profile.memoryRequest || '—'}

) : ( @@ -259,7 +246,7 @@ function DeployDefaultsFields({ )}
- + {readOnly ? (

{profile.memoryLimit}

) : ( @@ -276,7 +263,7 @@ function DeployDefaultsFields({ )}
- + {readOnly ? (

{profile.storageGi}

) : ( @@ -306,17 +293,19 @@ function CustomDomainPricing({ readOnly: boolean; onChange: (cycle: BillingCycle, value: number) => void; }) { + const t = useT(); + const b = t.dashboard.billing; return (
-

Custom domain + SSL

-

Flat fee per billing cycle (not resource-based)

+

{b.customDomainTitle}

+

{b.customDomainSub}

{cycles.map((cycle) => { const field = cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice'; return (
- + {readOnly ? (

{Number(customDomain[field]).toLocaleString('en-US')}

) : ( @@ -339,6 +328,8 @@ function CustomDomainPricing({ } export default function AdminBillingPage() { + const t = useT(); + const b = t.dashboard.billing; const queryClient = useQueryClient(); const [activeRuntime, setActiveRuntime] = useState('nodejs'); const [activeOptionalService, setActiveOptionalService] = useState('redis'); @@ -373,12 +364,12 @@ export default function AdminBillingPage() { queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] }); queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] }); queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] }); - toast.success('Billing plans saved'); + toast.success(b.saved); setEditing(false); setDraft(null); }, onError: (err: unknown) => { - toast.error(formatApiError(err, 'Failed to save billing plans')); + toast.error(formatApiError(err, b.saveFailed)); }, }); @@ -511,12 +502,9 @@ export default function AdminBillingPage() {

- Billing Plans + {b.title}

-

- Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost = - their usage × these rates. -

+

{b.subtitle}

{!editing ? ( ) : (
@@ -533,7 +521,7 @@ export default function AdminBillingPage() { disabled={saveMutation.isPending} className="btn-primary text-sm disabled:opacity-50" > - {saveMutation.isPending ? 'Saving...' : 'Save'} + {saveMutation.isPending ? b.saving : b.save}
)} @@ -551,38 +539,27 @@ export default function AdminBillingPage() {
-

How billing works

+

{b.howTitle}

    -
  • - Applications — user picks runtime resources in deploy; you set price per - core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for - example half the per-GB rate at half a gigabyte of memory). -
  • -
  • - Optional services (Redis, RabbitMQ) — same unit matrix per service as - runtimes; deploy wizard defaults are edited separately. CPU/RAM in the cost calculator bill in - proportion to actual limits (e.g. 500Mi counts as 0.5× the per-GB memory rate). -
  • -
  • - Deploy defaults — optional prefill only; changing them does not change - what existing apps pay unless the user chose those values at deploy. -
  • +
  • {b.howApps}
  • +
  • {b.howOptional}
  • +
  • {b.howDefaults}
{isLoading ? ( -
Loading...
+
{t.common.loading}
) : !display ? ( -
No pricing data
+
{b.noPricing}
) : ( <>
-

Application runtimes

-

Unit pricing per runtime (hourly / monthly / yearly)

+

{b.appRuntimes}

+

{b.appRuntimesSub}

@@ -605,7 +582,7 @@ export default function AdminBillingPage() {

- {runtimeTabs.find((t) => t.value === activeRuntime)?.label} — unit prices + {runtimeTabs.find((tab) => tab.value === activeRuntime)?.label} {b.unitPricesSuffix}

{editing && ( )}
@@ -629,10 +606,8 @@ export default function AdminBillingPage() {
-

Optional services

-

- Unit pricing per service (hourly / monthly / yearly) -

+

{b.optionalServices}

+

{b.optionalServicesSub}

@@ -655,7 +630,7 @@ export default function AdminBillingPage() {

- {optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} — unit prices + {optionalServiceTabs.find((tab) => tab.value === activeOptionalService)?.label} {b.unitPricesSuffix}

{editing && activeOptionalEntry && ( )}
@@ -681,10 +656,8 @@ export default function AdminBillingPage() {
-

Optional services — deploy defaults

-

- Prefill CPU, memory, and storage when a user enables each service in the deploy wizard -

+

{b.deployDefaults}

+

{b.deployDefaultsSub}

@@ -720,8 +693,8 @@ export default function AdminBillingPage() {
-

Add-ons

-

Flat fees not tied to a runtime

+

{b.addons}

+

{b.addonsSub}

) => api.patch('/lifecycle/settings', body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] }); - toast.success('Lifecycle settings updated'); + toast.success(b.lifecycleSaved); setEditing(false); }, onError: (err: unknown) => { - toast.error(formatApiError(err, 'Failed to save')); + toast.error(formatApiError(err, b.saveFailedShort)); }, }); @@ -785,29 +760,27 @@ function LifecycleSettingsSection() {
-

Data Retention & Deletion Policy

+

{b.retentionTitle}

{!editing && ( )}
-

- Configure how long user data is retained after plan expiration before permanent deletion. -

+

{b.retentionSub}

{isLoading ? ( -
Loading...
+
{t.common.loading}
) : editing ? (
-

Hourly Plans

+

{b.hourlyPlans}

- +
-

Monthly Plans

+

{b.monthlyPlans}

- +
-

Yearly Plans

+

{b.yearlyPlans}

- +
@@ -865,32 +838,32 @@ function LifecycleSettingsSection() {

- Hourly Plans + {b.hourlyPlans}

{settings?.hourly.deleteAfterHours ?? Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)} - hours + {b.hours}

- Monthly Plans + {b.monthlyPlans}

{settings?.monthly.deleteAfterDays ?? Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)} - days + {b.days}

- Yearly Plans + {b.yearlyPlans}

{settings?.yearly.deleteAfterDays ?? Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)} - days + {b.days}

diff --git a/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx b/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx index 835ca5d..6c2aff2 100644 --- a/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx +++ b/frontend/src/app/[lang]/dashboard/admin/clusters/page.tsx @@ -4,19 +4,21 @@ import { useState } 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 { Cluster, ClusterResources } from '@/types'; import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; function ResourcePanel({ clusterId }: { clusterId: string }) { + const cl = useT().dashboard.clusters; const { data, isLoading, error } = useQuery({ queryKey: ['cluster-resources', clusterId], queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data), refetchInterval: 30000, }); - if (isLoading) return
Loading resources...
; - if (error) return
Failed to load resources
; + if (isLoading) return
{cl.loadingResources}
; + if (error) return
{cl.resourcesFailed}
; if (!data) return null; const cpuCap = parseFloat(data.totalCpuCapacity); @@ -32,19 +34,19 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
{data.nodeCount}
-
Nodes
+
{cl.nodes}
{data.podCount}
-
Pods
+
{cl.pods}
{data.appCount}
-
Apps
+
{cl.apps}
{data.totalCpuCapacity}
-
Total CPU
+
{cl.totalCpu}
@@ -52,7 +54,7 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
- CPU Reserved + {cl.cpuReserved} {cpuUsedPct.toFixed(1)}%
@@ -62,12 +64,12 @@ function ResourcePanel({ clusterId }: { clusterId: string }) { />
- {data.totalCpuCapacity} capacity · {data.totalCpuAllocatable} allocatable + {data.totalCpuCapacity} {cl.capacity} · {data.totalCpuAllocatable} {cl.allocatable}
- Memory Reserved + {cl.memReserved} {memUsedPct.toFixed(1)}%
@@ -77,23 +79,23 @@ function ResourcePanel({ clusterId }: { clusterId: string }) { />
- {data.totalMemoryCapacity} capacity · {data.totalMemoryAllocatable} allocatable + {data.totalMemoryCapacity} {cl.capacity} · {data.totalMemoryAllocatable} {cl.allocatable}
{/* Nodes table */}
-

Nodes

+

{cl.nodes}

Resource{b.resourceCol} - {cycle} (T) + + {b.cycles[cycle]} ({t.common.currencyShort})
- {resourceLabels[row.resourceType]} + {(b.resources as Record)[row.resourceType] ?? row.resourceType}
- - - - - + + + + + @@ -149,15 +151,17 @@ interface ClusterTool { details?: Record; } -const TOOL_STATUS_BADGE: Record = { - installed: { label: 'Installed', cls: 'badge-green' }, - installing: { label: 'Installing…', cls: 'badge-yellow' }, - failed: { label: 'Failed', cls: 'badge-red' }, - not_installed: { label: 'Not installed', cls: 'badge-gray' }, - unknown: { label: 'Unknown', cls: 'badge-gray' }, +const TOOL_STATUS_CLS: Record = { + installed: 'badge-green', + installing: 'badge-yellow', + failed: 'badge-red', + not_installed: 'badge-gray', + unknown: 'badge-gray', }; function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) { + const t = useT(); + const cl = t.dashboard.clusters; const queryClient = useQueryClient(); const confirm = useConfirm(); const [showForm, setShowForm] = useState(false); @@ -173,18 +177,18 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT invalidate(); setShowForm(false); setFields({}); - toast.success(res.data?.message || `${tool.name} install started`); + toast.success(res.data?.message || cl.installStarted.replace('{name}', tool.name)); }, - onError: (err) => toast.error(apiErrorMessage(err, `Failed to install ${tool.name}`)), + onError: (err) => toast.error(apiErrorMessage(err, cl.installToolFailed.replace('{name}', tool.name))), }); const uninstallMutation = useMutation({ mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`), onSuccess: (res) => { invalidate(); - toast.success(res.data?.message || `${tool.name} removed`); + toast.success(res.data?.message || cl.toolRemoved.replace('{name}', tool.name)); }, - onError: (err) => toast.error(apiErrorMessage(err, `Failed to remove ${tool.name}`)), + onError: (err) => toast.error(apiErrorMessage(err, cl.removeToolFailed.replace('{name}', tool.name))), }); const unmetDeps = tool.dependencies.filter( @@ -193,7 +197,8 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT const depsBlocked = unmetDeps.length > 0; const isInstalled = tool.status === 'installed'; const isBusy = installMutation.isPending || uninstallMutation.isPending; - const badge = TOOL_STATUS_BADGE[tool.status]; + const badgeCls = TOOL_STATUS_CLS[tool.status]; + const badgeLabel = (cl.toolStatus as Record)[tool.status] ?? tool.status; const startInstall = () => { if (tool.installFields.length > 0) { @@ -206,7 +211,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT const submitForm = () => { for (const f of tool.installFields) { if (f.required && !fields[f.key]?.trim()) { - toast.error(`${f.label} is required`); + toast.error(cl.fieldRequired.replace('{field}', f.label)); return; } } @@ -219,7 +224,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT

{tool.name}

- {badge.label} + {badgeLabel} {tool.category}

{tool.description}

@@ -228,7 +233,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT )} {depsBlocked && !isInstalled && (

- Requires:{' '} + {cl.requires}{' '} {unmetDeps .map((d) => tools.find((t) => t.id === d)?.name || d) .join(', ')} @@ -244,9 +249,9 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT type="button" onClick={async () => { const ok = await confirm({ - title: `Remove ${tool.name}`, - message: `Uninstall "${tool.name}" from this cluster?`, - confirmText: 'Uninstall', + title: cl.removeToolTitle.replace('{name}', tool.name), + message: cl.removeToolMessage.replace('{name}', tool.name), + confirmText: cl.uninstallConfirm, variant: 'danger', }); if (ok) uninstallMutation.mutate(); @@ -254,7 +259,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT disabled={isBusy} className="btn-secondary text-sm text-red-600 disabled:opacity-50" > - {uninstallMutation.isPending ? 'Removing…' : 'Uninstall'} + {uninstallMutation.isPending ? cl.uninstalling : cl.uninstall} ) : ( )}

@@ -280,7 +285,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
+ {/* tool field labels come from backend */} - {installMutation.isPending ? 'Installing…' : 'Confirm install'} + {installMutation.isPending ? cl.installing : cl.confirmInstall}
@@ -319,6 +324,7 @@ function ClusterToolsPanel({ selectedClusterId: string | null; onSelectCluster: (id: string) => void; }) { + const cl = useT().dashboard.clusters; const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id; const { data: tools = [], isLoading, error } = useQuery({ @@ -334,12 +340,9 @@ function ClusterToolsPanel({

- Tools Management + {cl.toolsTitle}

-

- Install and manage infrastructure tools per cluster. Nothing is installed automatically — - add only what each cluster needs. -

+

{cl.toolsSubtitle}

{clusters.length > 1 && ( @@ -357,9 +360,9 @@ function ClusterToolsPanel({
{isLoading ? ( -

Loading tools…

+

{cl.loadingTools}

) : error ? ( -

Failed to load tools for this cluster.

+

{cl.toolsFailed}

) : (
{clusterId && @@ -373,6 +376,11 @@ function ClusterToolsPanel({ } export default function AdminClustersPage() { + const t = useT(); + const cl = t.dashboard.clusters; + const locale = useLocale(); + const clusterStatusLabel = (s?: string) => (s && (cl.clusterStatus as Record)[s]) || s || ''; + const clusterHealthLabel = (h?: string) => (h && (cl.clusterHealth as Record)[h]) || h || cl.clusterHealth.unknown; const queryClient = useQueryClient(); const confirm = useConfirm(); const [showForm, setShowForm] = useState(false); @@ -404,12 +412,12 @@ export default function AdminClustersPage() { }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); - toast.success('Cluster added & connection verified ✓'); + toast.success(cl.added); setShowForm(false); setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false }); }, onError: (err: any) => { - const message = err?.response?.data?.message || 'Failed to add cluster'; + const message = err?.response?.data?.message || cl.addFailed; toast.error(message); }, }); @@ -423,14 +431,14 @@ export default function AdminClustersPage() { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); const data = res.data; if (data.connected) { - toast.success(`Connection OK — Kubernetes ${data.version}`); + toast.success(cl.connectionOk.replace('{version}', data.version)); } else { - toast.error(`Connection failed: ${data.error}`); + toast.error(cl.connectionFailed.replace('{error}', data.error)); } setTestingId(null); }, onError: () => { - toast.error('Failed to test connection'); + toast.error(cl.testFailed); setTestingId(null); }, }); @@ -439,7 +447,7 @@ export default function AdminClustersPage() { mutationFn: (id: string) => api.delete(`/clusters/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-clusters'] }); - toast.success('Cluster removed'); + toast.success(cl.removed); }, }); @@ -459,11 +467,11 @@ export default function AdminClustersPage() {
-

Cluster Management

-

{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered

+

{cl.title}

+

{cl.count.replace('{n}', String(clusters.length))}

@@ -477,27 +485,25 @@ export default function AdminClustersPage() { {showForm && (
-

Register New Cluster

-

- The system will verify the Kubernetes connection before registering. -

+

{cl.registerNew}

+

{cl.verifyNote}

- + setForm({ ...form, name: e.target.value })} />
- + setForm({ ...form, apiServer: e.target.value })} />
- + setForm({ ...form, region: e.target.value })} />
- +
- +
- + setForm({ ...form, tags: e.target.value })} />
- + setForm({ ...form, description: e.target.value })} />
- +
NameStatusRolesCPU (Cap / Alloc)Memory (Cap / Alloc){cl.colName}{cl.colStatus}{cl.colRoles}{cl.colCpu}{cl.colMemory}