Localize admin cluster and billing-plan pages.
Move the cluster management page (resource panel, tools install/uninstall flow, statuses/health) and the billing-plans page (pricing matrices, deploy defaults, add-ons, data-retention settings) onto the dictionaries with locale-aware dates and RTL-aware tables. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
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 type {
|
import type {
|
||||||
PricingCatalog,
|
PricingCatalog,
|
||||||
PricingRateRow,
|
PricingRateRow,
|
||||||
@@ -15,18 +16,6 @@ import type {
|
|||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||||
|
|
||||||
const resourceLabels: Record<PricingResourceType, string> = {
|
|
||||||
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'];
|
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||||
|
|
||||||
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
|
/** PATCH body must match UpdatePricingCatalogDto (no runtimeOptions / label). */
|
||||||
@@ -89,15 +78,17 @@ function PricingMatrixTable({
|
|||||||
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
|
onChange: (resourceType: PricingResourceType, cycle: BillingCycle, value: number) => void;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const b = t.dashboard.billing;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
|
<table className="w-full text-sm border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left p-3 font-medium text-gray-600">Resource</th>
|
<th className="text-left rtl:text-right p-3 font-medium text-gray-600">{b.resourceCol}</th>
|
||||||
{cycles.map((cycle) => (
|
{cycles.map((cycle) => (
|
||||||
<th key={cycle} className="text-left p-3 font-medium text-gray-600 capitalize">
|
<th key={cycle} className="text-left rtl:text-right p-3 font-medium text-gray-600">
|
||||||
{cycle} (T)
|
{b.cycles[cycle]} ({t.common.currencyShort})
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,7 +97,7 @@ function PricingMatrixTable({
|
|||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.resourceType} className="border-t border-gray-100">
|
<tr key={row.resourceType} className="border-t border-gray-100">
|
||||||
<td className="p-3 font-medium text-gray-900">
|
<td className="p-3 font-medium text-gray-900">
|
||||||
{resourceLabels[row.resourceType]}
|
{(b.resources as Record<string, string>)[row.resourceType] ?? row.resourceType}
|
||||||
</td>
|
</td>
|
||||||
{cycles.map((cycle) => {
|
{cycles.map((cycle) => {
|
||||||
const field =
|
const field =
|
||||||
@@ -159,17 +150,16 @@ function DeployDefaultsFields({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
|
onUpdate: (patch: Partial<OptionalServiceProfileRow>) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const b = useT().dashboard.billing;
|
||||||
const isLogging = service === 'elasticsearch';
|
const isLogging = service === 'elasticsearch';
|
||||||
|
|
||||||
if (isLogging) {
|
if (isLogging) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50/40 p-4 space-y-3">
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">{b.loggingNote}</p>
|
||||||
Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">Log shipper CPU limit</label>
|
<label className="text-xs font-medium text-gray-600">{b.logShipperCpuLimit}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
|
<p className="text-sm font-mono mt-1">{profile.logShipperCpuLimit || '—'}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -182,7 +172,7 @@ function DeployDefaultsFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">Log shipper memory limit</label>
|
<label className="text-xs font-medium text-gray-600">{b.logShipperMemoryLimit}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
|
<p className="text-sm font-mono mt-1">{profile.logShipperMemoryLimit || '—'}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -201,13 +191,10 @@ function DeployDefaultsFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
|
<div className="rounded-lg border border-gray-200 bg-gray-50/60 p-4 space-y-3">
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">{b.defaultsNote}</p>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">CPU request</label>
|
<label className="text-xs font-medium text-gray-600">{b.cpuRequest}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -224,7 +211,7 @@ function DeployDefaultsFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">CPU limit</label>
|
<label className="text-xs font-medium text-gray-600">{b.cpuLimit}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -236,13 +223,13 @@ function DeployDefaultsFields({
|
|||||||
<option value="200m">200m</option>
|
<option value="200m">200m</option>
|
||||||
<option value="250m">250m</option>
|
<option value="250m">250m</option>
|
||||||
<option value="500m">500m</option>
|
<option value="500m">500m</option>
|
||||||
<option value="1">1 core</option>
|
<option value="1">{b.oneCore}</option>
|
||||||
<option value="2">2 cores</option>
|
<option value="2">{b.twoCores}</option>
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">Memory request</label>
|
<label className="text-xs font-medium text-gray-600">{b.memoryRequest}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -259,7 +246,7 @@ function DeployDefaultsFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-600">Memory limit</label>
|
<label className="text-xs font-medium text-gray-600">{b.memoryLimit}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -276,7 +263,7 @@ function DeployDefaultsFields({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="text-xs font-medium text-gray-600">Storage (Gi)</label>
|
<label className="text-xs font-medium text-gray-600">{b.storageGi}</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
|
<p className="text-sm font-mono mt-1">{profile.storageGi}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -306,17 +293,19 @@ function CustomDomainPricing({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onChange: (cycle: BillingCycle, value: number) => void;
|
onChange: (cycle: BillingCycle, value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const b = t.dashboard.billing;
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 rounded-lg p-4">
|
<div className="border border-gray-200 rounded-lg p-4">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">Custom domain + SSL</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">{b.customDomainTitle}</h3>
|
||||||
<p className="text-xs text-gray-500 mb-3">Flat fee per billing cycle (not resource-based)</p>
|
<p className="text-xs text-gray-500 mb-3">{b.customDomainSub}</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{cycles.map((cycle) => {
|
{cycles.map((cycle) => {
|
||||||
const field =
|
const field =
|
||||||
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
cycle === 'hourly' ? 'hourlyPrice' : cycle === 'monthly' ? 'monthlyPrice' : 'yearlyPrice';
|
||||||
return (
|
return (
|
||||||
<div key={cycle}>
|
<div key={cycle}>
|
||||||
<label className="text-xs text-gray-500 capitalize">{cycle} (T)</label>
|
<label className="text-xs text-gray-500">{b.cycles[cycle]} ({t.common.currencyShort})</label>
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
|
<p className="font-mono text-sm">{Number(customDomain[field]).toLocaleString('en-US')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -339,6 +328,8 @@ function CustomDomainPricing({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminBillingPage() {
|
export default function AdminBillingPage() {
|
||||||
|
const t = useT();
|
||||||
|
const b = t.dashboard.billing;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
const [activeRuntime, setActiveRuntime] = useState<string>('nodejs');
|
||||||
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
|
const [activeOptionalService, setActiveOptionalService] = useState<string>('redis');
|
||||||
@@ -373,12 +364,12 @@ export default function AdminBillingPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
|
queryClient.invalidateQueries({ queryKey: ['pricing-catalog'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
queryClient.invalidateQueries({ queryKey: ['custom-domain-price'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
queryClient.invalidateQueries({ queryKey: ['optional-services-pricing'] });
|
||||||
toast.success('Billing plans saved');
|
toast.success(b.saved);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
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() {
|
|||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title flex items-center gap-2">
|
<h1 className="page-title flex items-center gap-2">
|
||||||
<DollarSign className="w-6 h-6" /> Billing Plans
|
<DollarSign className="w-6 h-6" /> {b.title}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="page-subtitle">
|
<p className="page-subtitle">{b.subtitle}</p>
|
||||||
Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost =
|
|
||||||
their usage × these rates.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
<button
|
<button
|
||||||
@@ -524,7 +512,7 @@ export default function AdminBillingPage() {
|
|||||||
disabled={!catalog}
|
disabled={!catalog}
|
||||||
className="btn-primary flex items-center gap-2 shrink-0"
|
className="btn-primary flex items-center gap-2 shrink-0"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" /> Edit plans
|
<Edit2 className="w-4 h-4" /> {b.editPlans}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
@@ -533,7 +521,7 @@ export default function AdminBillingPage() {
|
|||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="btn-primary text-sm disabled:opacity-50"
|
className="btn-primary text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
{saveMutation.isPending ? b.saving : b.save}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -542,7 +530,7 @@ export default function AdminBillingPage() {
|
|||||||
}}
|
}}
|
||||||
className="btn-secondary text-sm"
|
className="btn-secondary text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -551,38 +539,27 @@ export default function AdminBillingPage() {
|
|||||||
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
|
<div className="rounded-xl border border-blue-100 bg-blue-50/50 p-4 flex gap-3 text-sm text-blue-900">
|
||||||
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
|
<Info className="w-5 h-5 shrink-0 text-blue-600 mt-0.5" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">How billing works</p>
|
<p className="font-medium">{b.howTitle}</p>
|
||||||
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
<ul className="list-disc list-inside text-blue-800/90 space-y-0.5 text-xs sm:text-sm">
|
||||||
<li>
|
<li>{b.howApps}</li>
|
||||||
<strong>Applications</strong> — user picks runtime resources in deploy; you set price per
|
<li>{b.howOptional}</li>
|
||||||
core, GB, base fee, and database addon. CPU and RAM in estimates bill proportionally (for
|
<li>{b.howDefaults}</li>
|
||||||
example half the per-GB rate at half a gigabyte of memory).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Optional services (Redis, RabbitMQ)</strong> — 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).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Deploy defaults</strong> — optional prefill only; changing them does not change
|
|
||||||
what existing apps pay unless the user chose those values at deploy.
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12 text-gray-400">Loading...</div>
|
<div className="text-center py-12 text-gray-400">{t.common.loading}</div>
|
||||||
) : !display ? (
|
) : !display ? (
|
||||||
<div className="text-center py-12 text-gray-400">No pricing data</div>
|
<div className="text-center py-12 text-gray-400">{b.noPricing}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="card space-y-4">
|
<div className="card space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
<Box className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Application runtimes</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{b.appRuntimes}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Unit pricing per runtime (hourly / monthly / yearly)</p>
|
<p className="text-sm text-gray-500 mt-0.5">{b.appRuntimesSub}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -605,7 +582,7 @@ export default function AdminBillingPage() {
|
|||||||
|
|
||||||
<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">
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
{runtimeTabs.find((t) => t.value === activeRuntime)?.label} — unit prices
|
{runtimeTabs.find((tab) => tab.value === activeRuntime)?.label} {b.unitPricesSuffix}
|
||||||
</h3>
|
</h3>
|
||||||
{editing && (
|
{editing && (
|
||||||
<button
|
<button
|
||||||
@@ -613,7 +590,7 @@ export default function AdminBillingPage() {
|
|||||||
onClick={() => fillYearlyFromMonthly('runtime')}
|
onClick={() => fillYearlyFromMonthly('runtime')}
|
||||||
className="btn-secondary text-xs"
|
className="btn-secondary text-xs"
|
||||||
>
|
>
|
||||||
Fill yearly from monthly ×12
|
{b.fillYearly}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -629,10 +606,8 @@ export default function AdminBillingPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
<Layers className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Optional services</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{b.optionalServices}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">{b.optionalServicesSub}</p>
|
||||||
Unit pricing per service (hourly / monthly / yearly)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -655,7 +630,7 @@ export default function AdminBillingPage() {
|
|||||||
|
|
||||||
<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">
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
{optionalServiceTabs.find((t) => t.value === activeOptionalService)?.label} — unit prices
|
{optionalServiceTabs.find((tab) => tab.value === activeOptionalService)?.label} {b.unitPricesSuffix}
|
||||||
</h3>
|
</h3>
|
||||||
{editing && activeOptionalEntry && (
|
{editing && activeOptionalEntry && (
|
||||||
<button
|
<button
|
||||||
@@ -663,7 +638,7 @@ export default function AdminBillingPage() {
|
|||||||
onClick={() => fillYearlyFromMonthly('optional')}
|
onClick={() => fillYearlyFromMonthly('optional')}
|
||||||
className="btn-secondary text-xs"
|
className="btn-secondary text-xs"
|
||||||
>
|
>
|
||||||
Fill yearly from monthly ×12
|
{b.fillYearly}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -681,10 +656,8 @@ export default function AdminBillingPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
<Server className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Optional services — deploy defaults</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{b.deployDefaults}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">{b.deployDefaultsSub}</p>
|
||||||
Prefill CPU, memory, and storage when a user enables each service in the deploy wizard
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -720,8 +693,8 @@ export default function AdminBillingPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
<Globe className="w-5 h-5 text-primary-600 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Add-ons</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{b.addons}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">Flat fees not tied to a runtime</p>
|
<p className="text-sm text-gray-500 mt-0.5">{b.addonsSub}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CustomDomainPricing
|
<CustomDomainPricing
|
||||||
@@ -740,6 +713,8 @@ export default function AdminBillingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LifecycleSettingsSection() {
|
function LifecycleSettingsSection() {
|
||||||
|
const t = useT();
|
||||||
|
const b = t.dashboard.billing;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [hourlyHours, setHourlyHours] = useState('');
|
const [hourlyHours, setHourlyHours] = useState('');
|
||||||
@@ -755,11 +730,11 @@ function LifecycleSettingsSection() {
|
|||||||
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
|
mutationFn: (body: Record<string, number>) => api.patch('/lifecycle/settings', body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['lifecycle-settings'] });
|
||||||
toast.success('Lifecycle settings updated');
|
toast.success(b.lifecycleSaved);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
toast.error(formatApiError(err, 'Failed to save'));
|
toast.error(formatApiError(err, b.saveFailedShort));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -785,29 +760,27 @@ function LifecycleSettingsSection() {
|
|||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="w-5 h-5 text-red-500" />
|
<Shield className="w-5 h-5 text-red-500" />
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Data Retention & Deletion Policy</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{b.retentionTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
{!editing && (
|
{!editing && (
|
||||||
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
|
<button onClick={startEditing} className="btn-secondary text-xs flex items-center gap-1">
|
||||||
<Edit2 className="w-3 h-3" /> Edit
|
<Edit2 className="w-3 h-3" /> {b.edit}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-gray-500 mb-4">{b.retentionSub}</p>
|
||||||
Configure how long user data is retained after plan expiration before permanent deletion.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-6 text-gray-400">Loading...</div>
|
<div className="text-center py-6 text-gray-400">{t.common.loading}</div>
|
||||||
) : editing ? (
|
) : editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
|
<div className="p-4 rounded-xl bg-blue-50 border border-blue-100">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Clock className="w-4 h-4 text-blue-600" />
|
<Clock className="w-4 h-4 text-blue-600" />
|
||||||
<h3 className="font-semibold text-blue-900">Hourly Plans</h3>
|
<h3 className="font-semibold text-blue-900">{b.hourlyPlans}</h3>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-xs text-blue-700 font-medium">Delete after (hours):</label>
|
<label className="text-xs text-blue-700 font-medium">{b.deleteAfterHours}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-field mt-1 text-sm"
|
className="input-field mt-1 text-sm"
|
||||||
@@ -820,9 +793,9 @@ function LifecycleSettingsSection() {
|
|||||||
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
|
<div className="p-4 rounded-xl bg-purple-50 border border-purple-100">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Clock className="w-4 h-4 text-purple-600" />
|
<Clock className="w-4 h-4 text-purple-600" />
|
||||||
<h3 className="font-semibold text-purple-900">Monthly Plans</h3>
|
<h3 className="font-semibold text-purple-900">{b.monthlyPlans}</h3>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-xs text-purple-700 font-medium">Delete after (days):</label>
|
<label className="text-xs text-purple-700 font-medium">{b.deleteAfterDays}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-field mt-1 text-sm"
|
className="input-field mt-1 text-sm"
|
||||||
@@ -835,9 +808,9 @@ function LifecycleSettingsSection() {
|
|||||||
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
|
<div className="p-4 rounded-xl bg-green-50 border border-green-100">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Clock className="w-4 h-4 text-green-600" />
|
<Clock className="w-4 h-4 text-green-600" />
|
||||||
<h3 className="font-semibold text-green-900">Yearly Plans</h3>
|
<h3 className="font-semibold text-green-900">{b.yearlyPlans}</h3>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-xs text-green-700 font-medium">Delete after (days):</label>
|
<label className="text-xs text-green-700 font-medium">{b.deleteAfterDays}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-field mt-1 text-sm"
|
className="input-field mt-1 text-sm"
|
||||||
@@ -850,14 +823,14 @@ function LifecycleSettingsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button onClick={() => setEditing(false)} className="btn-ghost">
|
<button onClick={() => setEditing(false)} className="btn-ghost">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="btn-primary disabled:opacity-50"
|
className="btn-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? 'Saving...' : 'Save Settings'}
|
{saveMutation.isPending ? b.saving : b.saveSettings}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,32 +838,32 @@ function LifecycleSettingsSection() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
|
<div className="p-4 rounded-xl bg-blue-50/50 border border-blue-100">
|
||||||
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
|
<h3 className="font-semibold text-blue-900 flex items-center gap-1 text-sm">
|
||||||
<Clock className="w-4 h-4" /> Hourly Plans
|
<Clock className="w-4 h-4" /> {b.hourlyPlans}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-2xl font-bold text-blue-700 mt-2">
|
<p className="text-2xl font-bold text-blue-700 mt-2">
|
||||||
{settings?.hourly.deleteAfterHours ??
|
{settings?.hourly.deleteAfterHours ??
|
||||||
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
Math.round((settings?.hourly.deleteAfterMs || 0) / 3600000)}
|
||||||
<span className="text-sm font-normal ml-1">hours</span>
|
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.hours}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
|
<div className="p-4 rounded-xl bg-purple-50/50 border border-purple-100">
|
||||||
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
|
<h3 className="font-semibold text-purple-900 flex items-center gap-1 text-sm">
|
||||||
<Clock className="w-4 h-4" /> Monthly Plans
|
<Clock className="w-4 h-4" /> {b.monthlyPlans}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-2xl font-bold text-purple-700 mt-2">
|
<p className="text-2xl font-bold text-purple-700 mt-2">
|
||||||
{settings?.monthly.deleteAfterDays ??
|
{settings?.monthly.deleteAfterDays ??
|
||||||
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
Math.round((settings?.monthly.deleteAfterMs || 0) / 86400000)}
|
||||||
<span className="text-sm font-normal ml-1">days</span>
|
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.days}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
|
<div className="p-4 rounded-xl bg-green-50/50 border border-green-100">
|
||||||
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
|
<h3 className="font-semibold text-green-900 flex items-center gap-1 text-sm">
|
||||||
<Clock className="w-4 h-4" /> Yearly Plans
|
<Clock className="w-4 h-4" /> {b.yearlyPlans}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-2xl font-bold text-green-700 mt-2">
|
<p className="text-2xl font-bold text-green-700 mt-2">
|
||||||
{settings?.yearly.deleteAfterDays ??
|
{settings?.yearly.deleteAfterDays ??
|
||||||
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
Math.round((settings?.yearly.deleteAfterMs || 0) / 86400000)}
|
||||||
<span className="text-sm font-normal ml-1">days</span>
|
<span className="text-sm font-normal ml-1 rtl:ml-0 rtl:mr-1">{b.days}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { useState } from 'react';
|
|||||||
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, useLocale } from '@/i18n/I18nProvider';
|
||||||
import type { Cluster, ClusterResources } from '@/types';
|
import type { Cluster, ClusterResources } from '@/types';
|
||||||
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
|
||||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||||
|
const cl = useT().dashboard.clusters;
|
||||||
const { data, isLoading, error } = useQuery<ClusterResources>({
|
const { data, isLoading, error } = useQuery<ClusterResources>({
|
||||||
queryKey: ['cluster-resources', clusterId],
|
queryKey: ['cluster-resources', clusterId],
|
||||||
queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data),
|
queryFn: () => api.get(`/clusters/${clusterId}/resources`).then((r) => r.data),
|
||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <div className="p-4 text-sm text-gray-500">Loading resources...</div>;
|
if (isLoading) return <div className="p-4 text-sm text-gray-500">{cl.loadingResources}</div>;
|
||||||
if (error) return <div className="p-4 text-sm text-red-500">Failed to load resources</div>;
|
if (error) return <div className="p-4 text-sm text-red-500">{cl.resourcesFailed}</div>;
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
const cpuCap = parseFloat(data.totalCpuCapacity);
|
const cpuCap = parseFloat(data.totalCpuCapacity);
|
||||||
@@ -32,19 +34,19 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
||||||
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
|
<div className="text-2xl font-bold text-blue-700">{data.nodeCount}</div>
|
||||||
<div className="text-xs text-blue-600">Nodes</div>
|
<div className="text-xs text-blue-600">{cl.nodes}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||||
<div className="text-2xl font-bold text-purple-700">{data.podCount}</div>
|
<div className="text-2xl font-bold text-purple-700">{data.podCount}</div>
|
||||||
<div className="text-xs text-purple-600">Pods</div>
|
<div className="text-xs text-purple-600">{cl.pods}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||||
<div className="text-2xl font-bold text-green-700">{data.appCount}</div>
|
<div className="text-2xl font-bold text-green-700">{data.appCount}</div>
|
||||||
<div className="text-xs text-green-600">Apps</div>
|
<div className="text-xs text-green-600">{cl.apps}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 rounded-lg p-3 text-center">
|
<div className="bg-orange-50 rounded-lg p-3 text-center">
|
||||||
<div className="text-2xl font-bold text-orange-700">{data.totalCpuCapacity}</div>
|
<div className="text-2xl font-bold text-orange-700">{data.totalCpuCapacity}</div>
|
||||||
<div className="text-xs text-orange-600">Total CPU</div>
|
<div className="text-xs text-orange-600">{cl.totalCpu}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-gray-600">CPU Reserved</span>
|
<span className="text-gray-600">{cl.cpuReserved}</span>
|
||||||
<span className="font-medium">{cpuUsedPct.toFixed(1)}%</span>
|
<span className="font-medium">{cpuUsedPct.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
@@ -62,12 +64,12 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{data.totalCpuCapacity} capacity · {data.totalCpuAllocatable} allocatable
|
{data.totalCpuCapacity} {cl.capacity} · {data.totalCpuAllocatable} {cl.allocatable}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-gray-600">Memory Reserved</span>
|
<span className="text-gray-600">{cl.memReserved}</span>
|
||||||
<span className="font-medium">{memUsedPct.toFixed(1)}%</span>
|
<span className="font-medium">{memUsedPct.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
@@ -77,23 +79,23 @@ function ResourcePanel({ clusterId }: { clusterId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{data.totalMemoryCapacity} capacity · {data.totalMemoryAllocatable} allocatable
|
{data.totalMemoryCapacity} {cl.capacity} · {data.totalMemoryAllocatable} {cl.allocatable}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nodes table */}
|
{/* Nodes table */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-gray-700 mb-2">Nodes</h4>
|
<h4 className="text-sm font-semibold text-gray-700 mb-2">{cl.nodes}</h4>
|
||||||
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
|
<div className="overflow-x-auto -mx-2 px-2 rounded-xl border border-gray-200">
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
<th className="px-4 py-2 text-left rtl:text-right text-xs font-medium text-gray-500 uppercase">{cl.colName}</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
<th className="px-4 py-2 text-left rtl:text-right text-xs font-medium text-gray-500 uppercase">{cl.colStatus}</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Roles</th>
|
<th className="px-4 py-2 text-left rtl:text-right text-xs font-medium text-gray-500 uppercase">{cl.colRoles}</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">CPU (Cap / Alloc)</th>
|
<th className="px-4 py-2 text-left rtl:text-right text-xs font-medium text-gray-500 uppercase">{cl.colCpu}</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Memory (Cap / Alloc)</th>
|
<th className="px-4 py-2 text-left rtl:text-right text-xs font-medium text-gray-500 uppercase">{cl.colMemory}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
@@ -149,15 +151,17 @@ interface ClusterTool {
|
|||||||
details?: Record<string, unknown>;
|
details?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_STATUS_BADGE: Record<ClusterTool['status'], { label: string; cls: string }> = {
|
const TOOL_STATUS_CLS: Record<ClusterTool['status'], string> = {
|
||||||
installed: { label: 'Installed', cls: 'badge-green' },
|
installed: 'badge-green',
|
||||||
installing: { label: 'Installing…', cls: 'badge-yellow' },
|
installing: 'badge-yellow',
|
||||||
failed: { label: 'Failed', cls: 'badge-red' },
|
failed: 'badge-red',
|
||||||
not_installed: { label: 'Not installed', cls: 'badge-gray' },
|
not_installed: 'badge-gray',
|
||||||
unknown: { label: 'Unknown', cls: 'badge-gray' },
|
unknown: 'badge-gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) {
|
function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterTool; tools: ClusterTool[] }) {
|
||||||
|
const t = useT();
|
||||||
|
const cl = t.dashboard.clusters;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -173,18 +177,18 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
invalidate();
|
invalidate();
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setFields({});
|
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({
|
const uninstallMutation = useMutation({
|
||||||
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
|
mutationFn: () => api.delete(`/clusters/${clusterId}/tools/${tool.id}`),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
invalidate();
|
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(
|
const unmetDeps = tool.dependencies.filter(
|
||||||
@@ -193,7 +197,8 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
const depsBlocked = unmetDeps.length > 0;
|
const depsBlocked = unmetDeps.length > 0;
|
||||||
const isInstalled = tool.status === 'installed';
|
const isInstalled = tool.status === 'installed';
|
||||||
const isBusy = installMutation.isPending || uninstallMutation.isPending;
|
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<string, string>)[tool.status] ?? tool.status;
|
||||||
|
|
||||||
const startInstall = () => {
|
const startInstall = () => {
|
||||||
if (tool.installFields.length > 0) {
|
if (tool.installFields.length > 0) {
|
||||||
@@ -206,7 +211,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
for (const f of tool.installFields) {
|
for (const f of tool.installFields) {
|
||||||
if (f.required && !fields[f.key]?.trim()) {
|
if (f.required && !fields[f.key]?.trim()) {
|
||||||
toast.error(`${f.label} is required`);
|
toast.error(cl.fieldRequired.replace('{field}', f.label));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +224,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
|
<h3 className="font-semibold text-gray-900">{tool.name}</h3>
|
||||||
<span className={`badge ${badge.cls}`}>{badge.label}</span>
|
<span className={`badge ${badgeCls}`}>{badgeLabel}</span>
|
||||||
<span className="badge badge-gray">{tool.category}</span>
|
<span className="badge badge-gray">{tool.category}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
|
<p className="text-sm text-gray-600 mt-1">{tool.description}</p>
|
||||||
@@ -228,7 +233,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
)}
|
)}
|
||||||
{depsBlocked && !isInstalled && (
|
{depsBlocked && !isInstalled && (
|
||||||
<p className="text-xs text-amber-600 mt-1">
|
<p className="text-xs text-amber-600 mt-1">
|
||||||
Requires:{' '}
|
{cl.requires}{' '}
|
||||||
{unmetDeps
|
{unmetDeps
|
||||||
.map((d) => tools.find((t) => t.id === d)?.name || d)
|
.map((d) => tools.find((t) => t.id === d)?.name || d)
|
||||||
.join(', ')}
|
.join(', ')}
|
||||||
@@ -244,9 +249,9 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: `Remove ${tool.name}`,
|
title: cl.removeToolTitle.replace('{name}', tool.name),
|
||||||
message: `Uninstall "${tool.name}" from this cluster?`,
|
message: cl.removeToolMessage.replace('{name}', tool.name),
|
||||||
confirmText: 'Uninstall',
|
confirmText: cl.uninstallConfirm,
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
});
|
});
|
||||||
if (ok) uninstallMutation.mutate();
|
if (ok) uninstallMutation.mutate();
|
||||||
@@ -254,7 +259,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
|
className="btn-secondary text-sm text-red-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uninstallMutation.isPending ? 'Removing…' : 'Uninstall'}
|
{uninstallMutation.isPending ? cl.uninstalling : cl.uninstall}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -262,13 +267,13 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
onClick={startInstall}
|
onClick={startInstall}
|
||||||
disabled={isBusy || depsBlocked}
|
disabled={isBusy || depsBlocked}
|
||||||
className="btn-primary text-sm disabled:opacity-50"
|
className="btn-primary text-sm disabled:opacity-50"
|
||||||
title={depsBlocked ? 'Install required tools first' : undefined}
|
title={depsBlocked ? cl.installRequiredFirst : undefined}
|
||||||
>
|
>
|
||||||
{installMutation.isPending
|
{installMutation.isPending
|
||||||
? 'Installing…'
|
? cl.installing
|
||||||
: tool.status === 'failed'
|
: tool.status === 'failed'
|
||||||
? 'Repair'
|
? cl.repair
|
||||||
: 'Install'}
|
: cl.install}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +285,7 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
<div key={f.key}>
|
<div key={f.key}>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{f.label}{f.required ? ' *' : ''}
|
{f.label}{f.required ? ' *' : ''}
|
||||||
</label>
|
</label>{/* tool field labels come from backend */}
|
||||||
<input
|
<input
|
||||||
type={f.type === 'email' ? 'email' : 'text'}
|
type={f.type === 'email' ? 'email' : 'text'}
|
||||||
className="input-field text-sm"
|
className="input-field text-sm"
|
||||||
@@ -298,10 +303,10 @@ function ToolRow({ clusterId, tool, tools }: { clusterId: string; tool: ClusterT
|
|||||||
disabled={installMutation.isPending}
|
disabled={installMutation.isPending}
|
||||||
className="btn-primary text-sm"
|
className="btn-primary text-sm"
|
||||||
>
|
>
|
||||||
{installMutation.isPending ? 'Installing…' : 'Confirm install'}
|
{installMutation.isPending ? cl.installing : cl.confirmInstall}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
|
<button type="button" onClick={() => setShowForm(false)} className="btn-ghost text-sm">
|
||||||
Cancel
|
{t.common.cancel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,6 +324,7 @@ function ClusterToolsPanel({
|
|||||||
selectedClusterId: string | null;
|
selectedClusterId: string | null;
|
||||||
onSelectCluster: (id: string) => void;
|
onSelectCluster: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const cl = useT().dashboard.clusters;
|
||||||
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
const clusterId = selectedClusterId || clusters.find((c) => c.isDefault)?.id || clusters[0]?.id;
|
||||||
|
|
||||||
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
|
const { data: tools = [], isLoading, error } = useQuery<ClusterTool[]>({
|
||||||
@@ -334,12 +340,9 @@ function ClusterToolsPanel({
|
|||||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<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">
|
||||||
<ScrollText className="w-5 h-5 text-indigo-600" /> Tools Management
|
<ScrollText className="w-5 h-5 text-indigo-600" /> {cl.toolsTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">{cl.toolsSubtitle}</p>
|
||||||
Install and manage infrastructure tools per cluster. Nothing is installed automatically —
|
|
||||||
add only what each cluster needs.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{clusters.length > 1 && (
|
{clusters.length > 1 && (
|
||||||
<select
|
<select
|
||||||
@@ -349,7 +352,7 @@ function ClusterToolsPanel({
|
|||||||
>
|
>
|
||||||
{clusters.map((c) => (
|
{clusters.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}{c.isDefault ? ' (default)' : ''}
|
{c.name}{c.isDefault ? cl.defaultSuffix : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -357,9 +360,9 @@ function ClusterToolsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-gray-500">Loading tools…</p>
|
<p className="text-sm text-gray-500">{cl.loadingTools}</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-sm text-red-500">Failed to load tools for this cluster.</p>
|
<p className="text-sm text-red-500">{cl.toolsFailed}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{clusterId &&
|
{clusterId &&
|
||||||
@@ -373,6 +376,11 @@ function ClusterToolsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminClustersPage() {
|
export default function AdminClustersPage() {
|
||||||
|
const t = useT();
|
||||||
|
const cl = t.dashboard.clusters;
|
||||||
|
const locale = useLocale();
|
||||||
|
const clusterStatusLabel = (s?: string) => (s && (cl.clusterStatus as Record<string, string>)[s]) || s || '';
|
||||||
|
const clusterHealthLabel = (h?: string) => (h && (cl.clusterHealth as Record<string, string>)[h]) || h || cl.clusterHealth.unknown;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -404,12 +412,12 @@ export default function AdminClustersPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
toast.success('Cluster added & connection verified ✓');
|
toast.success(cl.added);
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
|
setForm({ name: '', description: '', apiServer: '', kubeconfig: '', region: '', provider: '', weight: 1, tags: '', isDefault: false });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
const message = err?.response?.data?.message || 'Failed to add cluster';
|
const message = err?.response?.data?.message || cl.addFailed;
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -423,14 +431,14 @@ export default function AdminClustersPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
toast.success(`Connection OK — Kubernetes ${data.version}`);
|
toast.success(cl.connectionOk.replace('{version}', data.version));
|
||||||
} else {
|
} else {
|
||||||
toast.error(`Connection failed: ${data.error}`);
|
toast.error(cl.connectionFailed.replace('{error}', data.error));
|
||||||
}
|
}
|
||||||
setTestingId(null);
|
setTestingId(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error('Failed to test connection');
|
toast.error(cl.testFailed);
|
||||||
setTestingId(null);
|
setTestingId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -439,7 +447,7 @@ export default function AdminClustersPage() {
|
|||||||
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
mutationFn: (id: string) => api.delete(`/clusters/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-clusters'] });
|
||||||
toast.success('Cluster removed');
|
toast.success(cl.removed);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -459,11 +467,11 @@ export default function AdminClustersPage() {
|
|||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Cluster Management</h1>
|
<h1 className="page-title">{cl.title}</h1>
|
||||||
<p className="page-subtitle">{clusters.length} cluster{clusters.length !== 1 ? 's' : ''} registered</p>
|
<p className="page-subtitle">{cl.count.replace('{n}', String(clusters.length))}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||||
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add Cluster'}
|
{showForm ? <><X className="w-4 h-4 inline" /> {t.common.cancel}</> : `+ ${cl.addCluster}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -477,27 +485,25 @@ export default function AdminClustersPage() {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="card space-y-4 animate-slide-up">
|
<div className="card space-y-4 animate-slide-up">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Register New Cluster</h2>
|
<h2 className="text-lg font-semibold text-gray-900">{cl.registerNew}</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">{cl.verifyNote}</p>
|
||||||
The system will verify the Kubernetes connection before registering.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.name}</label>
|
||||||
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<input className="input-field" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Server URL</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.apiServer}</label>
|
||||||
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
|
<input className="input-field" placeholder="https://k8s-api:6443" value={form.apiServer} onChange={(e) => setForm({ ...form, apiServer: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.region}</label>
|
||||||
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
<input className="input-field" placeholder="us-east-1" value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.provider}</label>
|
||||||
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
||||||
<option value="">Select provider</option>
|
<option value="">{cl.selectProvider}</option>
|
||||||
<option value="aws">AWS (EKS)</option>
|
<option value="aws">AWS (EKS)</option>
|
||||||
<option value="gcp">GCP (GKE)</option>
|
<option value="gcp">GCP (GKE)</option>
|
||||||
<option value="azure">Azure (AKS)</option>
|
<option value="azure">Azure (AKS)</option>
|
||||||
@@ -505,7 +511,7 @@ export default function AdminClustersPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Weight</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.weight}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
@@ -515,44 +521,44 @@ export default function AdminClustersPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tags</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.tags}</label>
|
||||||
<input
|
<input
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="ssd, production, iran"
|
placeholder={cl.tagsPlaceholder}
|
||||||
value={form.tags}
|
value={form.tags}
|
||||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.description}</label>
|
||||||
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<input className="input-field" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kubeconfig (YAML)</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.kubeconfig}</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="input-field font-mono text-xs"
|
className="input-field font-mono text-xs"
|
||||||
rows={8}
|
rows={8}
|
||||||
placeholder="Paste your kubeconfig here..."
|
placeholder={cl.kubeconfigPlaceholder}
|
||||||
value={form.kubeconfig}
|
value={form.kubeconfig}
|
||||||
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
|
onChange={(e) => setForm({ ...form, kubeconfig: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="isDefault"
|
id="isDefault"
|
||||||
checked={form.isDefault}
|
checked={form.isDefault}
|
||||||
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
onChange={(e) => setForm({ ...form, isDefault: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="isDefault" className="text-sm text-gray-700">Set as default cluster</label>
|
<label htmlFor="isDefault" className="text-sm text-gray-700">{cl.setDefault}</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => createMutation.mutate(form)}
|
onClick={() => createMutation.mutate(form)}
|
||||||
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
|
disabled={!form.name || !form.apiServer || !form.kubeconfig || createMutation.isPending}
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> Verifying connection & adding...</> : 'Add Cluster'}
|
{createMutation.isPending ? <><RotateCw className="w-4 h-4 inline animate-spin" /> {cl.verifying}</> : cl.addCluster}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -573,8 +579,8 @@ export default function AdminClustersPage() {
|
|||||||
) : clusters.length === 0 ? (
|
) : clusters.length === 0 ? (
|
||||||
<div className="card text-center py-16">
|
<div className="card text-center py-16">
|
||||||
<Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
<Server className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||||
<p className="text-gray-600 font-medium">No clusters registered yet.</p>
|
<p className="text-gray-600 font-medium">{cl.noClusters}</p>
|
||||||
<p className="text-gray-400 text-sm mt-1">Add a Kubernetes cluster to start deploying applications.</p>
|
<p className="text-gray-400 text-sm mt-1">{cl.noClustersHint}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@@ -591,14 +597,14 @@ export default function AdminClustersPage() {
|
|||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
<h3 className="font-semibold text-gray-900">{cluster.name}</h3>
|
||||||
{cluster.isDefault && (
|
{cluster.isDefault && (
|
||||||
<span className="badge badge-blue">Default</span>
|
<span className="badge badge-blue">{cl.defaultBadge}</span>
|
||||||
)}
|
)}
|
||||||
<span className={`badge ${
|
<span className={`badge ${
|
||||||
cluster.status === 'active' ? 'badge-green'
|
cluster.status === 'active' ? 'badge-green'
|
||||||
: cluster.status === 'maintenance' ? 'badge-yellow'
|
: cluster.status === 'maintenance' ? 'badge-yellow'
|
||||||
: 'badge-red'
|
: 'badge-red'
|
||||||
}`}>
|
}`}>
|
||||||
{cluster.status}
|
{clusterStatusLabel(cluster.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`badge ${
|
<span className={`badge ${
|
||||||
cluster.healthStatus === 'healthy' ? 'badge-green'
|
cluster.healthStatus === 'healthy' ? 'badge-green'
|
||||||
@@ -606,17 +612,17 @@ export default function AdminClustersPage() {
|
|||||||
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
|
: cluster.healthStatus === 'unhealthy' ? 'badge-red'
|
||||||
: 'badge-gray'
|
: 'badge-gray'
|
||||||
}`}>
|
}`}>
|
||||||
health: {cluster.healthStatus || 'unknown'}
|
{cl.healthBadge.replace('{status}', clusterHealthLabel(cluster.healthStatus))}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge badge-gray">weight {cluster.weight || 1}</span>
|
<span className="badge badge-gray">{cl.weightBadge.replace('{n}', String(cluster.weight || 1))}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 truncate">
|
<p className="text-sm text-gray-500 truncate">
|
||||||
{cluster.provider || 'Unknown'} · {cluster.region || 'N/A'} · {cluster.apiServer}
|
{cluster.provider || cl.na} · {cluster.region || cl.na} · {cluster.apiServer}
|
||||||
</p>
|
</p>
|
||||||
{cluster.healthMessage && (
|
{cluster.healthMessage && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
{cluster.healthMessage}
|
{cluster.healthMessage}
|
||||||
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString()}` : ''}
|
{cluster.lastHealthCheckedAt ? ` · ${new Date(cluster.lastHealthCheckedAt).toLocaleString(locale)}` : ''}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{cluster.tags?.length > 0 && (
|
{cluster.tags?.length > 0 && (
|
||||||
@@ -630,7 +636,7 @@ export default function AdminClustersPage() {
|
|||||||
)}
|
)}
|
||||||
{cluster.availableResources && (
|
{cluster.availableResources && (
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
CPU {cluster.availableResources.totalCpuAllocatable || 'n/a'} · Memory {cluster.availableResources.totalMemoryAllocatable || 'n/a'} · Pods {cluster.availableResources.podCount ?? 'n/a'} · Apps {cluster.availableResources.appCount ?? 'n/a'}
|
CPU {cluster.availableResources.totalCpuAllocatable || cl.na} · {cl.memory} {cluster.availableResources.totalMemoryAllocatable || cl.na} · {cl.pods} {cluster.availableResources.podCount ?? cl.na} · {cl.apps} {cluster.availableResources.appCount ?? cl.na}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -640,23 +646,23 @@ export default function AdminClustersPage() {
|
|||||||
onClick={() => toggleResources(cluster.id)}
|
onClick={() => toggleResources(cluster.id)}
|
||||||
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
|
className={`btn-ghost text-sm ${expandedResources.has(cluster.id) ? 'bg-purple-50 text-purple-700' : ''}`}
|
||||||
>
|
>
|
||||||
<BarChart3 className="w-4 h-4 inline" /> Resources
|
<BarChart3 className="w-4 h-4 inline" /> {cl.resources}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => testMutation.mutate(cluster.id)}
|
onClick={() => testMutation.mutate(cluster.id)}
|
||||||
disabled={testingId === cluster.id}
|
disabled={testingId === cluster.id}
|
||||||
className="btn-ghost text-sm disabled:opacity-50"
|
className="btn-ghost text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> Testing...</> : <><Plug className="w-3 h-3 inline" /> Test</>}
|
{testingId === cluster.id ? <><Clock className="w-3 h-3 inline animate-spin" /> {cl.testing}</> : <><Plug className="w-3 h-3 inline" /> {cl.test}</>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const ok = await confirm({ title: 'Remove Cluster', message: `Are you sure you want to remove "${cluster.name}"?`, confirmText: 'Remove', variant: 'danger' });
|
const ok = await confirm({ title: cl.removeTitle, message: cl.removeMessage.replace('{name}', cluster.name), confirmText: cl.remove, variant: 'danger' });
|
||||||
if (ok) deleteMutation.mutate(cluster.id);
|
if (ok) deleteMutation.mutate(cluster.id);
|
||||||
}}
|
}}
|
||||||
className="text-sm text-red-600 hover:text-red-800 font-medium"
|
className="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
>
|
>
|
||||||
Remove
|
{cl.remove}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -581,6 +581,153 @@ const en: Dictionary = {
|
|||||||
clusterStatus: { active: 'active', inactive: 'inactive', error: 'error' },
|
clusterStatus: { active: 'active', inactive: 'inactive', error: 'error' },
|
||||||
clusterHealth: { healthy: 'healthy', degraded: 'degraded', unhealthy: 'unhealthy', unknown: 'unknown' },
|
clusterHealth: { healthy: 'healthy', degraded: 'degraded', unhealthy: 'unhealthy', unknown: 'unknown' },
|
||||||
},
|
},
|
||||||
|
clusters: {
|
||||||
|
title: 'Cluster Management',
|
||||||
|
count: '{n} cluster(s) registered',
|
||||||
|
addCluster: 'Add Cluster',
|
||||||
|
registerNew: 'Register New Cluster',
|
||||||
|
verifyNote: 'The system will verify the Kubernetes connection before registering.',
|
||||||
|
name: 'Name',
|
||||||
|
apiServer: 'API Server URL',
|
||||||
|
region: 'Region',
|
||||||
|
provider: 'Provider',
|
||||||
|
selectProvider: 'Select provider',
|
||||||
|
weight: 'Weight',
|
||||||
|
tags: 'Tags',
|
||||||
|
tagsPlaceholder: 'ssd, production, iran',
|
||||||
|
description: 'Description',
|
||||||
|
kubeconfig: 'Kubeconfig (YAML)',
|
||||||
|
kubeconfigPlaceholder: 'Paste your kubeconfig here...',
|
||||||
|
setDefault: 'Set as default cluster',
|
||||||
|
verifying: 'Verifying connection & adding...',
|
||||||
|
added: 'Cluster added & connection verified ✓',
|
||||||
|
addFailed: 'Failed to add cluster',
|
||||||
|
connectionOk: 'Connection OK — Kubernetes {version}',
|
||||||
|
connectionFailed: 'Connection failed: {error}',
|
||||||
|
testFailed: 'Failed to test connection',
|
||||||
|
removed: 'Cluster removed',
|
||||||
|
noClusters: 'No clusters registered yet.',
|
||||||
|
noClustersHint: 'Add a Kubernetes cluster to start deploying applications.',
|
||||||
|
defaultBadge: 'Default',
|
||||||
|
healthBadge: 'health: {status}',
|
||||||
|
weightBadge: 'weight {n}',
|
||||||
|
resources: 'Resources',
|
||||||
|
testing: 'Testing...',
|
||||||
|
test: 'Test',
|
||||||
|
remove: 'Remove',
|
||||||
|
removeTitle: 'Remove Cluster',
|
||||||
|
removeMessage: 'Are you sure you want to remove “{name}”?',
|
||||||
|
loadingResources: 'Loading resources...',
|
||||||
|
resourcesFailed: 'Failed to load resources',
|
||||||
|
nodes: 'Nodes',
|
||||||
|
pods: 'Pods',
|
||||||
|
apps: 'Apps',
|
||||||
|
totalCpu: 'Total CPU',
|
||||||
|
cpuReserved: 'CPU Reserved',
|
||||||
|
memReserved: 'Memory Reserved',
|
||||||
|
memory: 'Memory',
|
||||||
|
capacity: 'capacity',
|
||||||
|
allocatable: 'allocatable',
|
||||||
|
colName: 'Name',
|
||||||
|
colStatus: 'Status',
|
||||||
|
colRoles: 'Roles',
|
||||||
|
colCpu: 'CPU (Cap / Alloc)',
|
||||||
|
colMemory: 'Memory (Cap / Alloc)',
|
||||||
|
toolsTitle: 'Tools Management',
|
||||||
|
toolsSubtitle: 'Install and manage infrastructure tools per cluster. Nothing is installed automatically — add only what each cluster needs.',
|
||||||
|
defaultSuffix: ' (default)',
|
||||||
|
loadingTools: 'Loading tools…',
|
||||||
|
toolsFailed: 'Failed to load tools for this cluster.',
|
||||||
|
toolStatus: {
|
||||||
|
installed: 'Installed',
|
||||||
|
installing: 'Installing…',
|
||||||
|
failed: 'Failed',
|
||||||
|
not_installed: 'Not installed',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
},
|
||||||
|
requires: 'Requires:',
|
||||||
|
uninstall: 'Uninstall',
|
||||||
|
uninstalling: 'Removing…',
|
||||||
|
install: 'Install',
|
||||||
|
installing: 'Installing…',
|
||||||
|
repair: 'Repair',
|
||||||
|
confirmInstall: 'Confirm install',
|
||||||
|
installRequiredFirst: 'Install required tools first',
|
||||||
|
removeToolTitle: 'Remove {name}',
|
||||||
|
removeToolMessage: 'Uninstall “{name}” from this cluster?',
|
||||||
|
uninstallConfirm: 'Uninstall',
|
||||||
|
installStarted: '{name} install started',
|
||||||
|
installToolFailed: 'Failed to install {name}',
|
||||||
|
toolRemoved: '{name} removed',
|
||||||
|
removeToolFailed: 'Failed to remove {name}',
|
||||||
|
fieldRequired: '{field} is required',
|
||||||
|
clusterStatus: { active: 'active', maintenance: 'maintenance', inactive: 'inactive', error: 'error' },
|
||||||
|
clusterHealth: { healthy: 'healthy', degraded: 'degraded', unhealthy: 'unhealthy', unknown: 'unknown' },
|
||||||
|
na: 'N/A',
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
cycles: { hourly: 'Hourly', monthly: 'Monthly', yearly: 'Yearly' },
|
||||||
|
resources: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
resourceCol: 'Resource',
|
||||||
|
title: 'Billing Plans',
|
||||||
|
subtitle: 'Set unit prices per resource. Users choose CPU, memory, and storage when deploying; cost = their usage × these rates.',
|
||||||
|
editPlans: 'Edit plans',
|
||||||
|
saving: 'Saving...',
|
||||||
|
save: 'Save',
|
||||||
|
saved: 'Billing plans saved',
|
||||||
|
saveFailed: 'Failed to save billing plans',
|
||||||
|
howTitle: 'How billing works',
|
||||||
|
howApps: '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).',
|
||||||
|
howOptional: '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).',
|
||||||
|
howDefaults: 'Deploy defaults — optional prefill only; changing them does not change what existing apps pay unless the user chose those values at deploy.',
|
||||||
|
noPricing: 'No pricing data',
|
||||||
|
appRuntimes: 'Application runtimes',
|
||||||
|
appRuntimesSub: 'Unit pricing per runtime (hourly / monthly / yearly)',
|
||||||
|
unitPricesSuffix: '— unit prices',
|
||||||
|
fillYearly: 'Fill yearly from monthly ×12',
|
||||||
|
optionalServices: 'Optional services',
|
||||||
|
optionalServicesSub: 'Unit pricing per service (hourly / monthly / yearly)',
|
||||||
|
deployDefaults: 'Optional services — deploy defaults',
|
||||||
|
deployDefaultsSub: 'Prefill CPU, memory, and storage when a user enables each service in the deploy wizard',
|
||||||
|
addons: 'Add-ons',
|
||||||
|
addonsSub: 'Flat fees not tied to a runtime',
|
||||||
|
customDomainTitle: 'Custom domain + SSL',
|
||||||
|
customDomainSub: 'Flat fee per billing cycle (not resource-based)',
|
||||||
|
loggingNote: 'Prefilled limits for Fluent Bit log shippers (billed per enabled workload when logging is on).',
|
||||||
|
logShipperCpuLimit: 'Log shipper CPU limit',
|
||||||
|
logShipperMemoryLimit: 'Log shipper memory limit',
|
||||||
|
defaultsNote: '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.',
|
||||||
|
cpuRequest: 'CPU request',
|
||||||
|
cpuLimit: 'CPU limit',
|
||||||
|
memoryRequest: 'Memory request',
|
||||||
|
memoryLimit: 'Memory limit',
|
||||||
|
storageGi: 'Storage (Gi)',
|
||||||
|
oneCore: '1 core',
|
||||||
|
twoCores: '2 cores',
|
||||||
|
retentionTitle: 'Data Retention & Deletion Policy',
|
||||||
|
edit: 'Edit',
|
||||||
|
retentionSub: 'Configure how long user data is retained after plan expiration before permanent deletion.',
|
||||||
|
hourlyPlans: 'Hourly Plans',
|
||||||
|
monthlyPlans: 'Monthly Plans',
|
||||||
|
yearlyPlans: 'Yearly Plans',
|
||||||
|
deleteAfterHours: 'Delete after (hours):',
|
||||||
|
deleteAfterDays: 'Delete after (days):',
|
||||||
|
saveSettings: 'Save Settings',
|
||||||
|
lifecycleSaved: 'Lifecycle settings updated',
|
||||||
|
saveFailedShort: 'Failed to save',
|
||||||
|
hours: 'hours',
|
||||||
|
days: 'days',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -580,6 +580,153 @@ const fa = {
|
|||||||
clusterStatus: { active: 'فعال', inactive: 'غیرفعال', error: 'خطا' },
|
clusterStatus: { active: 'فعال', inactive: 'غیرفعال', error: 'خطا' },
|
||||||
clusterHealth: { healthy: 'سالم', degraded: 'نزولیافته', unhealthy: 'ناسالم', unknown: 'نامشخص' },
|
clusterHealth: { healthy: 'سالم', degraded: 'نزولیافته', unhealthy: 'ناسالم', unknown: 'نامشخص' },
|
||||||
},
|
},
|
||||||
|
clusters: {
|
||||||
|
title: 'مدیریت کلاسترها',
|
||||||
|
count: '{n} کلاستر ثبتشده',
|
||||||
|
addCluster: 'افزودن کلاستر',
|
||||||
|
registerNew: 'ثبت کلاستر جدید',
|
||||||
|
verifyNote: 'سیستم پیش از ثبت، اتصال کوبرنتیز را بررسی میکند.',
|
||||||
|
name: 'نام',
|
||||||
|
apiServer: 'آدرس API Server',
|
||||||
|
region: 'منطقه',
|
||||||
|
provider: 'ارائهدهنده',
|
||||||
|
selectProvider: 'انتخاب ارائهدهنده',
|
||||||
|
weight: 'وزن',
|
||||||
|
tags: 'برچسبها',
|
||||||
|
tagsPlaceholder: 'ssd, production, iran',
|
||||||
|
description: 'توضیحات',
|
||||||
|
kubeconfig: 'Kubeconfig (YAML)',
|
||||||
|
kubeconfigPlaceholder: 'kubeconfig خود را اینجا بچسبان…',
|
||||||
|
setDefault: 'تنظیم بهعنوان کلاستر پیشفرض',
|
||||||
|
verifying: 'در حال بررسی اتصال و افزودن…',
|
||||||
|
added: 'کلاستر افزوده و اتصال تأیید شد ✓',
|
||||||
|
addFailed: 'افزودن کلاستر ناموفق بود',
|
||||||
|
connectionOk: 'اتصال موفق — کوبرنتیز {version}',
|
||||||
|
connectionFailed: 'اتصال ناموفق: {error}',
|
||||||
|
testFailed: 'تست اتصال ناموفق بود',
|
||||||
|
removed: 'کلاستر حذف شد',
|
||||||
|
noClusters: 'هنوز کلاستری ثبت نشده.',
|
||||||
|
noClustersHint: 'برای شروع انتشار اپها، یک کلاستر کوبرنتیز اضافه کن.',
|
||||||
|
defaultBadge: 'پیشفرض',
|
||||||
|
healthBadge: 'سلامت: {status}',
|
||||||
|
weightBadge: 'وزن {n}',
|
||||||
|
resources: 'منابع',
|
||||||
|
testing: 'در حال تست…',
|
||||||
|
test: 'تست',
|
||||||
|
remove: 'حذف',
|
||||||
|
removeTitle: 'حذف کلاستر',
|
||||||
|
removeMessage: 'مطمئنی میخواهی «{name}» را حذف کنی؟',
|
||||||
|
loadingResources: 'در حال بارگذاری منابع…',
|
||||||
|
resourcesFailed: 'بارگذاری منابع ناموفق بود',
|
||||||
|
nodes: 'نودها',
|
||||||
|
pods: 'پادها',
|
||||||
|
apps: 'اپها',
|
||||||
|
totalCpu: 'کل CPU',
|
||||||
|
cpuReserved: 'CPU رزروشده',
|
||||||
|
memReserved: 'حافظهٔ رزروشده',
|
||||||
|
memory: 'حافظه',
|
||||||
|
capacity: 'ظرفیت',
|
||||||
|
allocatable: 'قابلتخصیص',
|
||||||
|
colName: 'نام',
|
||||||
|
colStatus: 'وضعیت',
|
||||||
|
colRoles: 'نقشها',
|
||||||
|
colCpu: 'CPU (ظرفیت / تخصیص)',
|
||||||
|
colMemory: 'حافظه (ظرفیت / تخصیص)',
|
||||||
|
toolsTitle: 'مدیریت ابزارها',
|
||||||
|
toolsSubtitle: 'ابزارهای زیرساخت را برای هر کلاستر نصب و مدیریت کن. هیچچیز بهصورت خودکار نصب نمیشود — فقط آنچه هر کلاستر نیاز دارد را اضافه کن.',
|
||||||
|
defaultSuffix: ' (پیشفرض)',
|
||||||
|
loadingTools: 'در حال بارگذاری ابزارها…',
|
||||||
|
toolsFailed: 'بارگذاری ابزارهای این کلاستر ناموفق بود.',
|
||||||
|
toolStatus: {
|
||||||
|
installed: 'نصبشده',
|
||||||
|
installing: 'در حال نصب…',
|
||||||
|
failed: 'ناموفق',
|
||||||
|
not_installed: 'نصبنشده',
|
||||||
|
unknown: 'نامشخص',
|
||||||
|
},
|
||||||
|
requires: 'نیازمند:',
|
||||||
|
uninstall: 'حذف نصب',
|
||||||
|
uninstalling: 'در حال حذف…',
|
||||||
|
install: 'نصب',
|
||||||
|
installing: 'در حال نصب…',
|
||||||
|
repair: 'تعمیر',
|
||||||
|
confirmInstall: 'تأیید نصب',
|
||||||
|
installRequiredFirst: 'اول ابزارهای موردنیاز را نصب کن',
|
||||||
|
removeToolTitle: 'حذف {name}',
|
||||||
|
removeToolMessage: '«{name}» از این کلاستر حذف شود؟',
|
||||||
|
uninstallConfirm: 'حذف نصب',
|
||||||
|
installStarted: '{name} نصب آغاز شد',
|
||||||
|
installToolFailed: 'نصب {name} ناموفق بود',
|
||||||
|
toolRemoved: '{name} حذف شد',
|
||||||
|
removeToolFailed: 'حذف {name} ناموفق بود',
|
||||||
|
fieldRequired: '{field} الزامی است',
|
||||||
|
clusterStatus: { active: 'فعال', maintenance: 'تعمیر', inactive: 'غیرفعال', error: 'خطا' },
|
||||||
|
clusterHealth: { healthy: 'سالم', degraded: 'نزولیافته', unhealthy: 'ناسالم', unknown: 'نامشخص' },
|
||||||
|
na: 'نامشخص',
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
cycles: { hourly: 'ساعتی', monthly: 'ماهانه', yearly: 'سالانه' },
|
||||||
|
resources: {
|
||||||
|
base_fee: 'هزینهٔ پایه',
|
||||||
|
cpu_per_core: 'CPU (هر هسته)',
|
||||||
|
memory_per_gb: 'حافظه (هر GB)',
|
||||||
|
storage_per_gb: 'فضای ذخیره (هر GB)',
|
||||||
|
database_addon: 'افزونهٔ دیتابیس',
|
||||||
|
redis_addon: 'Redis',
|
||||||
|
rabbitmq_addon: 'RabbitMQ',
|
||||||
|
elasticsearch_addon: 'Elasticsearch',
|
||||||
|
custom_domain_addon: 'دامنهٔ اختصاصی + SSL',
|
||||||
|
},
|
||||||
|
resourceCol: 'منبع',
|
||||||
|
title: 'پلنهای صورتحساب',
|
||||||
|
subtitle: 'قیمت واحد هر منبع را تعیین کن. کاربران هنگام انتشار CPU، حافظه و فضای ذخیره را انتخاب میکنند؛ هزینه = مصرف آنها × این نرخها.',
|
||||||
|
editPlans: 'ویرایش پلنها',
|
||||||
|
saving: 'در حال ذخیره…',
|
||||||
|
save: 'ذخیره',
|
||||||
|
saved: 'پلنهای صورتحساب ذخیره شد',
|
||||||
|
saveFailed: 'ذخیرهٔ پلنهای صورتحساب ناموفق بود',
|
||||||
|
howTitle: 'صورتحساب چطور کار میکند',
|
||||||
|
howApps: 'اپلیکیشنها — کاربر منابع رانتایم را در انتشار انتخاب میکند؛ تو قیمت هر هسته، GB، هزینهٔ پایه و افزونهٔ دیتابیس را تعیین میکنی. CPU و RAM در برآوردها بهتناسب صورتحساب میشوند (مثلاً نیمِ نرخِ هر GB برای نیم گیگابایت حافظه).',
|
||||||
|
howOptional: 'سرویسهای اختیاری (Redis، RabbitMQ) — همان ماتریس واحد هر سرویس مثل رانتایمها؛ پیشفرضهای ویزارد انتشار جداگانه ویرایش میشوند. CPU/RAM در محاسبهگر هزینه بهتناسب محدودیتهای واقعی صورتحساب میشوند (مثلاً 500Mi معادل ۰٫۵× نرخِ حافظهٔ هر GB).',
|
||||||
|
howDefaults: 'پیشفرضهای انتشار — فقط پیشپرکردنِ اختیاری؛ تغییر آنها هزینهٔ اپهای موجود را عوض نمیکند مگر کاربر همان مقادیر را هنگام انتشار انتخاب کرده باشد.',
|
||||||
|
noPricing: 'دادهی قیمتی وجود ندارد',
|
||||||
|
appRuntimes: 'رانتایمهای اپلیکیشن',
|
||||||
|
appRuntimesSub: 'قیمتگذاری واحد هر رانتایم (ساعتی / ماهانه / سالانه)',
|
||||||
|
unitPricesSuffix: '— قیمتهای واحد',
|
||||||
|
fillYearly: 'پر کردن سالانه از ماهانه ×۱۲',
|
||||||
|
optionalServices: 'سرویسهای اختیاری',
|
||||||
|
optionalServicesSub: 'قیمتگذاری واحد هر سرویس (ساعتی / ماهانه / سالانه)',
|
||||||
|
deployDefaults: 'سرویسهای اختیاری — پیشفرضهای انتشار',
|
||||||
|
deployDefaultsSub: 'پیشپرکردن CPU، حافظه و فضای ذخیره هنگام فعالسازی هر سرویس در ویزارد انتشار',
|
||||||
|
addons: 'افزونهها',
|
||||||
|
addonsSub: 'هزینههای ثابت بدون وابستگی به رانتایم',
|
||||||
|
customDomainTitle: 'دامنهٔ اختصاصی + SSL',
|
||||||
|
customDomainSub: 'هزینهٔ ثابت در هر دورهٔ صورتحساب (نه بر اساس منابع)',
|
||||||
|
loggingNote: 'محدودیتهای پیشپرشده برای ارسالکنندههای لاگ Fluent Bit (بهازای هر workload فعال هنگام روشنبودن لاگینگ صورتحساب میشود).',
|
||||||
|
logShipperCpuLimit: 'محدودیت CPU ارسالکنندهٔ لاگ',
|
||||||
|
logShipperMemoryLimit: 'محدودیت حافظهٔ ارسالکنندهٔ لاگ',
|
||||||
|
defaultsNote: 'هنگامی که کاربر این سرویس را در انتشار فعال میکند نمایش داده میشود. او میتواند CPU، حافظه و فضای ذخیره را در «منابع و پیکربندی» تغییر دهد؛ صورتحساب واقعی از انتخابهای او × قیمتهای واحد زیر استفاده میکند.',
|
||||||
|
cpuRequest: 'درخواست CPU',
|
||||||
|
cpuLimit: 'محدودیت CPU',
|
||||||
|
memoryRequest: 'درخواست حافظه',
|
||||||
|
memoryLimit: 'محدودیت حافظه',
|
||||||
|
storageGi: 'فضای ذخیره (Gi)',
|
||||||
|
oneCore: '۱ هسته',
|
||||||
|
twoCores: '۲ هسته',
|
||||||
|
retentionTitle: 'سیاست نگهداری و حذف داده',
|
||||||
|
edit: 'ویرایش',
|
||||||
|
retentionSub: 'تنظیم کن دادههای کاربر پس از انقضای پلن چه مدت پیش از حذف دائمی نگه داشته شود.',
|
||||||
|
hourlyPlans: 'پلنهای ساعتی',
|
||||||
|
monthlyPlans: 'پلنهای ماهانه',
|
||||||
|
yearlyPlans: 'پلنهای سالانه',
|
||||||
|
deleteAfterHours: 'حذف پس از (ساعت):',
|
||||||
|
deleteAfterDays: 'حذف پس از (روز):',
|
||||||
|
saveSettings: 'ذخیرهٔ تنظیمات',
|
||||||
|
lifecycleSaved: 'تنظیمات چرخهٔ عمر بهروزرسانی شد',
|
||||||
|
saveFailedShort: 'ذخیره ناموفق بود',
|
||||||
|
hours: 'ساعت',
|
||||||
|
days: 'روز',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user