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:
@@ -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 : '••••••••••••'}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user