Localize workload-logs and external-access panels.

Add components.workloadLogs, components.deployStatus and
components.externalAccess dictionaries; move the workload logs panel and
the service external-access panel onto them (tabs, live-refresh hints,
access modes, countdown units, toasts) with RTL-aware spacing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 18:25:45 +03:30
parent f64d341c80
commit 63e368f464
5 changed files with 183 additions and 50 deletions
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { useT } from '@/i18n/I18nProvider';
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
@@ -14,6 +15,7 @@ export function ServiceExternalAccessPanel({
appId: string;
app: Application;
}) {
const ea = useT().components.externalAccess;
const queryClient = useQueryClient();
const [accessTarget, setAccessTarget] = useState<ServiceAccessTarget>('database');
const [accessDuration, setAccessDuration] = useState(60);
@@ -25,13 +27,13 @@ export function ServiceExternalAccessPanel({
const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = [];
const hasDb =
(app.databaseType && app.databaseType !== 'none') || app.productType === 'managed_database';
if (hasDb) accessTargetOptions.push({ value: 'database', label: 'Database' });
if (hasDb) accessTargetOptions.push({ value: 'database', label: ea.targets.database });
if (app.enableRedis || app.productType === 'managed_redis') {
accessTargetOptions.push({ value: 'redis', label: 'Redis' });
accessTargetOptions.push({ value: 'redis', label: ea.targets.redis });
}
if (app.enableRabbitmq || app.productType === 'managed_rabbitmq') {
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' });
accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' });
accessTargetOptions.push({ value: 'rabbitmq_amqp', label: ea.targets.rabbitmq_amqp });
accessTargetOptions.push({ value: 'rabbitmq_management', label: ea.targets.rabbitmq_management });
}
const hasAccessTargets = accessTargetOptions.length > 0;
@@ -67,11 +69,11 @@ export function ServiceExternalAccessPanel({
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success(accessPersistent ? 'Permanent external access enabled' : 'Temporary external access enabled');
toast.success(accessPersistent ? ea.permanentEnabled : ea.temporaryEnabled);
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to enable access');
toast.error(msg || ea.enableFailed);
},
});
@@ -80,9 +82,9 @@ export function ServiceExternalAccessPanel({
onSuccess: () => {
refetchAccessGrants();
queryClient.invalidateQueries({ queryKey: ['access-grants', appId] });
toast.success('Access revoked');
toast.success(ea.revoked);
},
onError: () => toast.error('Failed to revoke access'),
onError: () => toast.error(ea.revokeFailed),
});
const copyToClipboard = (text: string, field: string) => {
@@ -95,16 +97,16 @@ export function ServiceExternalAccessPanel({
accessTargetOptions.find((o) => o.value === target)?.label || target;
const formatAccessCountdown = (grant: ServiceAccessGrant) => {
if (grant.persistent) return 'Permanent (until revoked)';
if (grant.persistent) return ea.permanentUntilRevoked;
const ms = new Date(grant.expiresAt).getTime() - accessNow;
if (ms <= 0) return 'Expired';
if (ms <= 0) return ea.expired;
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
if (h > 0) return `${h}${ea.hShort} ${m}${ea.mShort} ${s}${ea.sShort}`;
if (m > 0) return `${m}${ea.mShort} ${s}${ea.sShort}`;
return `${s}${ea.sShort}`;
};
if (!hasAccessTargets) return null;
@@ -112,22 +114,21 @@ export function ServiceExternalAccessPanel({
return (
<div className="card">
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
<ExternalLink className="w-5 h-5" /> External access
<ExternalLink className="w-5 h-5" /> {ea.title}
</h2>
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4 flex items-start gap-2">
<ShieldAlert className="w-4 h-4 shrink-0 mt-0.5" />
Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the session ends or you
revoke it. Use short durations for temporary access; permanent keeps the port open until revoked.
{ea.warning}
</p>
{!app.latestImageTag ? (
<p className="text-sm text-gray-500">Deploy the service first to enable external access.</p>
<p className="text-sm text-gray-500">{ea.deployFirst}</p>
) : (
<>
<div className="bg-gray-50 rounded-xl p-4 mb-4 space-y-4">
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-[160px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Service</label>
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.service}</label>
<select
value={accessTarget}
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
@@ -141,7 +142,7 @@ export function ServiceExternalAccessPanel({
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-gray-600 block mb-1">Access mode</label>
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.accessMode}</label>
<div className="flex gap-2">
<button
type="button"
@@ -152,7 +153,7 @@ export function ServiceExternalAccessPanel({
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Temporary
{ea.temporary}
</button>
<button
type="button"
@@ -163,7 +164,7 @@ export function ServiceExternalAccessPanel({
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100'
}`}
>
Always open
{ea.alwaysOpen}
</button>
</div>
</div>
@@ -171,7 +172,7 @@ export function ServiceExternalAccessPanel({
{!accessPersistent && (
<div>
<label className="text-xs font-medium text-gray-600 block mb-1">Duration</label>
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.duration}</label>
<div className="flex gap-2 flex-wrap">
{[30, 60, 240].map((mins) => (
<button
@@ -193,7 +194,7 @@ export function ServiceExternalAccessPanel({
{accessPersistent && (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
The port stays exposed until you click Revoke. Only use this when you need a stable external endpoint.
{ea.persistentNote}
</p>
)}
@@ -203,12 +204,12 @@ export function ServiceExternalAccessPanel({
disabled={createAccessMutation.isPending}
className="btn-primary text-sm"
>
{createAccessMutation.isPending ? 'Opening…' : accessPersistent ? 'Open port permanently' : 'Enable access'}
{createAccessMutation.isPending ? ea.opening : accessPersistent ? ea.openPermanently : ea.enableAccess}
</button>
</div>
{accessGrants.filter((g) => g.status === 'active').length === 0 ? (
<p className="text-sm text-gray-500">No active external access sessions.</p>
<p className="text-sm text-gray-500">{ea.noSessions}</p>
) : (
<div className="space-y-3">
{accessGrants
@@ -219,8 +220,8 @@ export function ServiceExternalAccessPanel({
<div>
<span className="text-sm font-semibold text-gray-800">{accessTargetLabel(grant.target)}</span>
{grant.persistent && (
<span className="ml-2 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
Always open
<span className="ml-2 rtl:ml-0 rtl:mr-2 text-xs font-medium text-amber-700 bg-amber-50 px-2 py-0.5 rounded-full">
{ea.alwaysOpen}
</span>
)}
<span className="ml-2 text-xs text-gray-500">{formatAccessCountdown(grant)}</span>
@@ -231,12 +232,12 @@ export function ServiceExternalAccessPanel({
disabled={revokeAccessMutation.isPending}
className="btn-secondary text-xs text-red-600 border-red-200 hover:bg-red-50"
>
Revoke
{ea.revoke}
</button>
</div>
<div className="space-y-1.5 text-sm font-mono">
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">Endpoint</span>
<span className="text-gray-500 text-xs font-sans">{ea.endpoint}</span>
<div className="flex items-center gap-2">
<span className="text-gray-800">
{grant.host}:{grant.port}
@@ -256,7 +257,7 @@ export function ServiceExternalAccessPanel({
</div>
{grant.connection.url && (
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 text-xs font-sans">URL</span>
<span className="text-gray-500 text-xs font-sans">{ea.url}</span>
<div className="flex items-center gap-2 max-w-[70%]">
<span className="text-gray-800 truncate text-xs" title={grant.connection.url}>
{showAccessSecret ? grant.connection.url : '••••••••••••'}
+23 -19
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { useT } from '@/i18n/I18nProvider';
import { FileText, ChevronDown, Monitor, Hammer, Pin } from 'lucide-react';
const statusColors: Record<string, string> = {
@@ -29,6 +30,9 @@ export function WorkloadLogsPanel({
isStopped?: boolean;
emptyPodMessage?: string;
}) {
const t = useT();
const wl = t.components.workloadLogs;
const statusLabel = (s: string) => (t.components.deployStatus as Record<string, string>)[s] ?? s;
const [showLogs, setShowLogs] = useState(false);
const [logTab, setLogTab] = useState<'pod' | 'build'>('pod');
const logsEndRef = useRef<HTMLPreElement>(null);
@@ -60,38 +64,38 @@ export function WorkloadLogsPanel({
const podPlaceholder =
emptyPodMessage ||
(isRunning
? 'Loading logs...'
? wl.loadingLogs
: isStopped
? 'Service is stopped. Start it to see logs.'
: 'Waiting for workload pods to be ready...');
? wl.serviceStopped
: wl.waitingPods);
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5" /> Logs
<FileText className="w-5 h-5" /> {wl.logs}
</h2>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-3 rtl:space-x-reverse">
{showLogs && logTab === 'pod' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="text-xs text-gray-400 flex items-center space-x-1 rtl:space-x-reverse">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span>Live (every 3s)</span>
<span>{wl.liveEvery3s}</span>
</span>
)}
{showBuildLogs && showLogs && logTab === 'build' && (
<span className="text-xs text-gray-400 flex items-center space-x-1">
<span className="text-xs text-gray-400 flex items-center space-x-1 rtl:space-x-reverse">
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>Auto-refresh (every 5s)</span>
<span>{wl.autoEvery5s}</span>
</span>
)}
<button type="button" onClick={() => setShowLogs(!showLogs)} className="btn-secondary text-sm">
{showLogs ? (
<>
<ChevronDown className="w-4 h-4 inline" /> Hide logs
<ChevronDown className="w-4 h-4 inline" /> {wl.hideLogs}
</>
) : (
<>
<FileText className="w-4 h-4 inline" /> Show logs
<FileText className="w-4 h-4 inline" /> {wl.showLogs}
</>
)}
</button>
@@ -109,7 +113,7 @@ export function WorkloadLogsPanel({
logTab === 'pod' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Monitor className="w-4 h-4 inline" /> Pod logs
<Monitor className="w-4 h-4 inline" /> {wl.podLogs}
</button>
<button
type="button"
@@ -118,11 +122,11 @@ export function WorkloadLogsPanel({
logTab === 'build' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
<Hammer className="w-4 h-4 inline" /> Build logs
<Hammer className="w-4 h-4 inline" /> {wl.buildLogs}
</button>
</div>
) : (
<p className="text-xs text-gray-500">Workload pod output (no image build for this service).</p>
<p className="text-xs text-gray-500">{wl.noBuildNote}</p>
)}
{logTab === 'pod' && (
@@ -142,19 +146,19 @@ export function WorkloadLogsPanel({
<Pin className="w-3 h-3 inline" /> {buildLogsData.version}
</span>
<span className={`badge ${statusColors[buildLogsData.status] || 'badge-gray'}`}>
{buildLogsData.status}
{statusLabel(buildLogsData.status)}
</span>
</div>
)}
<pre className="bg-gray-900 text-blue-300 p-4 rounded-xl text-xs font-mono overflow-x-auto max-h-[500px] overflow-y-auto whitespace-pre-wrap break-words">
{buildLogsData?.buildLog ||
(buildLogsData?.status === 'building'
? 'Build in progress... Logs will appear when complete.'
? wl.buildInProgress
: buildLogsData?.status === 'pending'
? 'Build is pending...'
? wl.buildPending
: buildLogsData?.status === 'no_deployment'
? 'No deployments yet. Deploy your app to see build logs.'
: 'No build logs available for this deployment.')}
? wl.noDeployments
: wl.noBuildLogs)}
</pre>
</div>
)}