- Full Detail
+ {t('logs.fullDetail')}
{JSON.stringify(log.detail ?? log, null, 2)}
diff --git a/it0-web-admin/src/app/(admin)/audit/replay/page.tsx b/it0-web-admin/src/app/(admin)/audit/replay/page.tsx
index 04a699f..2b35f48 100644
--- a/it0-web-admin/src/app/(admin)/audit/replay/page.tsx
+++ b/it0-web-admin/src/app/(admin)/audit/replay/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys';
@@ -60,11 +61,11 @@ interface Filters {
// -- Constants ---------------------------------------------------------------
const STATUS_OPTIONS = [
- { label: 'All', value: '' },
- { label: 'Completed', value: 'completed' },
- { label: 'Failed', value: 'failed' },
- { label: 'Cancelled', value: 'cancelled' },
- { label: 'Running', value: 'running' },
+ { labelKey: 'replay.statuses.all', value: '' },
+ { labelKey: 'replay.statuses.completed', value: 'completed' },
+ { labelKey: 'replay.statuses.failed', value: 'failed' },
+ { labelKey: 'replay.statuses.cancelled', value: 'cancelled' },
+ { labelKey: 'replay.statuses.running', value: 'running' },
];
const STATUS_BADGE_STYLES: Record = {
@@ -85,15 +86,15 @@ const EVENT_TYPE_STYLES: Record = {
session_completed: 'bg-green-100 text-green-800',
};
-const EVENT_TYPE_LABELS: Record = {
- command_executed: 'Command Executed',
- output_received: 'Output Received',
- approval_requested: 'Approval Requested',
- approval_granted: 'Approval Granted',
- approval_denied: 'Approval Denied',
- error: 'Error',
- session_started: 'Session Started',
- session_completed: 'Session Completed',
+const EVENT_TYPE_LABEL_KEYS: Record = {
+ command_executed: 'replay.events.types.commandExecuted',
+ output_received: 'replay.events.types.outputReceived',
+ approval_requested: 'replay.events.types.approvalRequested',
+ approval_granted: 'replay.events.types.approvalGranted',
+ approval_denied: 'replay.events.types.approvalDenied',
+ error: 'replay.events.types.error',
+ session_started: 'replay.events.types.sessionStarted',
+ session_completed: 'replay.events.types.sessionCompleted',
};
const RISK_LEVEL_STYLES: Record = {
@@ -146,6 +147,7 @@ function truncateId(id: string, maxLen = 12): string {
// -- Main Component ----------------------------------------------------------
export default function SessionReplayPage() {
+ const { t } = useTranslation('audit');
const [filters, setFilters] = useState({
dateFrom: '',
dateTo: '',
@@ -315,16 +317,16 @@ export default function SessionReplayPage() {
{/* Header */}
-
Session Replay
+
{t('replay.title')}
- Review agent session execution history
+ {t('replay.subtitle')}
{/* Filters */}
- Date From
+ {t('replay.filters.dateFrom')}
- Date To
+ {t('replay.filters.dateTo')}
- Status
+ {t('replay.filters.status')}
updateFilter('status', e.target.value)}
@@ -350,18 +352,18 @@ export default function SessionReplayPage() {
>
{STATUS_OPTIONS.map((opt) => (
- {opt.label}
+ {t(opt.labelKey)}
))}
- Search
+ {t('replay.filters.search')}
updateFilter('search', e.target.value)}
- placeholder="Session ID or task description..."
+ placeholder={t('replay.filters.searchPlaceholder')}
className="w-full px-2 py-1.5 bg-input border rounded-md text-sm"
/>
@@ -369,11 +371,11 @@ export default function SessionReplayPage() {
{/* Loading / Error for sessions */}
{sessionsLoading && (
-
Loading sessions...
+
{t('replay.loading')}
)}
{sessionsError && (
- Failed to load sessions: {(sessionsError as Error).message}
+ {t('replay.loadError')} {(sessionsError as Error).message}
)}
@@ -383,12 +385,12 @@ export default function SessionReplayPage() {
- Session ID
- Task Description
- Status
- Duration
- Commands
- Started At
+ {t('replay.table.sessionId')}
+ {t('replay.table.taskDescription')}
+ {t('replay.table.status')}
+ {t('replay.table.duration')}
+ {t('replay.table.commands')}
+ {t('replay.table.startedAt')}
@@ -438,7 +440,7 @@ export default function SessionReplayPage() {
colSpan={6}
className="py-8 text-center text-muted-foreground"
>
- No sessions found for the current filters.
+ {t('replay.empty')}
)}
@@ -446,7 +448,7 @@ export default function SessionReplayPage() {
{total > 0 && (
- Showing {sessions.length} of {total} sessions
+ {t('replay.showing', { count: sessions.length, total })}
)}
@@ -460,7 +462,7 @@ export default function SessionReplayPage() {
-
Session Replay
+
{t('replay.panel.title')}
- ID: {' '}
+ {t('replay.panel.id')} {' '}
{selectedSession.id}
- Duration: {' '}
+ {t('replay.panel.duration')} {' '}
{formatDuration(selectedSession.durationMs)}
- Commands: {' '}
+ {t('replay.panel.commands')} {' '}
{selectedSession.commandCount}
{selectedSession.serverTargets.length > 0 && (
- Servers: {' '}
+ {t('replay.panel.servers')} {' '}
{selectedSession.serverTargets.join(', ')}
)}
@@ -496,7 +498,7 @@ export default function SessionReplayPage() {
onClick={() => setSelectedSessionId(null)}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent transition-colors self-start"
>
- Close
+ {t('replay.panel.close')}
@@ -513,14 +515,14 @@ export default function SessionReplayPage() {
disabled={events.length === 0}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
- Play
+ {t('replay.playback.play')}
) : (
- Pause
+ {t('replay.playback.pause')}
)}
- Reset
+ {t('replay.playback.reset')}
- Show All
+ {t('replay.playback.showAll')}
-
Speed:
+
{t('replay.playback.speed')}
{PLAYBACK_SPEEDS.map((speed) => (
{visibleEventIndex >= 0
- ? `${Math.min(visibleEventIndex + 1, events.length)} / ${events.length} events`
- : `${events.length} events`}
+ ? t('replay.playback.eventsProgress', { current: Math.min(visibleEventIndex + 1, events.length), total: events.length })
+ : `${events.length} ${t('replay.playback.events')}`}
@@ -568,12 +570,12 @@ export default function SessionReplayPage() {
{/* Events Loading / Error */}
{eventsLoading && (
- Loading session events...
+ {t('replay.events.loading')}
)}
{eventsError && (
- Error loading events: {(eventsError as Error).message}
+ {t('replay.events.loadError')} {(eventsError as Error).message}
)}
@@ -582,13 +584,13 @@ export default function SessionReplayPage() {
{visibleEvents.length === 0 && events.length === 0 && (
- No events recorded for this session.
+ {t('replay.events.empty')}
)}
{visibleEvents.length === 0 && events.length > 0 && (
- Press Play to begin the session replay.
+ {t('replay.events.pressPlay')}
)}
@@ -625,6 +627,7 @@ function EventCard({
isOutputExpanded: boolean;
onToggleOutput: () => void;
}) {
+ const { t } = useTranslation('audit');
const relativeTime = useMemo(
() => formatRelativeTime(event.timestamp, sessionStartedAt),
[event.timestamp, sessionStartedAt],
@@ -643,7 +646,7 @@ function EventCard({
EVENT_TYPE_STYLES[event.type] ?? 'bg-muted text-muted-foreground',
)}
>
- {EVENT_TYPE_LABELS[event.type] ?? event.type}
+ {EVENT_TYPE_LABEL_KEYS[event.type] ? t(EVENT_TYPE_LABEL_KEYS[event.type]) : event.type}
{event.data.riskLevel && (
- {isOutputExpanded ? 'Collapse' : 'Expand full output'}
+ {isOutputExpanded ? t('replay.events.collapse') : t('replay.events.expandFullOutput')}
)}
diff --git a/it0-web-admin/src/app/(admin)/communication/page.tsx b/it0-web-admin/src/app/(admin)/communication/page.tsx
index 3ffa926..aa778b1 100644
--- a/it0-web-admin/src/app/(admin)/communication/page.tsx
+++ b/it0-web-admin/src/app/(admin)/communication/page.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/infrastructure/api/api-client';
import { queryKeys } from '@/infrastructure/api/query-keys';
@@ -50,70 +51,77 @@ interface PaginatedResponse
{
// ── Channel config field definitions ────────────────────────────────────────
-const CHANNEL_CONFIG_FIELDS: Record = {
+const CHANNEL_CONFIG_FIELDS: Record = {
email: [
- { key: 'smtpHost', label: 'SMTP Host' },
- { key: 'smtpPort', label: 'SMTP Port' },
- { key: 'smtpUser', label: 'SMTP Username' },
- { key: 'smtpPass', label: 'SMTP Password', type: 'password' },
- { key: 'fromAddress', label: 'From Address' },
+ { key: 'smtpHost', labelKey: 'channels.fields.smtpHost' },
+ { key: 'smtpPort', labelKey: 'channels.fields.smtpPort' },
+ { key: 'smtpUser', labelKey: 'channels.fields.username' },
+ { key: 'smtpPass', labelKey: 'channels.fields.password', type: 'password' },
+ { key: 'fromAddress', labelKey: 'channels.fields.fromAddress' },
],
telegram: [
- { key: 'botToken', label: 'Bot Token', type: 'password' },
- { key: 'chatId', label: 'Chat ID' },
+ { key: 'botToken', labelKey: 'channels.fields.botToken', type: 'password' },
+ { key: 'chatId', labelKey: 'channels.fields.chatId' },
],
sms: [
- { key: 'provider', label: 'Provider' },
- { key: 'apiKey', label: 'API Key', type: 'password' },
- { key: 'fromNumber', label: 'From Number' },
+ { key: 'provider', labelKey: 'channels.fields.provider' },
+ { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
+ { key: 'fromNumber', labelKey: 'channels.fields.fromNumber' },
],
push: [
- { key: 'provider', label: 'Provider' },
- { key: 'apiKey', label: 'API Key', type: 'password' },
- { key: 'appId', label: 'App ID' },
+ { key: 'provider', labelKey: 'channels.fields.provider' },
+ { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
+ { key: 'appId', labelKey: 'channels.fields.appId' },
],
voice_call: [
- { key: 'provider', label: 'Provider' },
- { key: 'apiKey', label: 'API Key', type: 'password' },
- { key: 'fromNumber', label: 'From Number' },
+ { key: 'provider', labelKey: 'channels.fields.provider' },
+ { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
+ { key: 'fromNumber', labelKey: 'channels.fields.fromNumber' },
],
wechat_work: [
- { key: 'corpId', label: 'Corp ID' },
- { key: 'agentId', label: 'Agent ID' },
- { key: 'secret', label: 'Secret', type: 'password' },
+ { key: 'corpId', labelKey: 'channels.fields.corpId' },
+ { key: 'agentId', labelKey: 'channels.fields.agentId' },
+ { key: 'secret', labelKey: 'channels.fields.secret', type: 'password' },
],
voice_service: [
- { key: 'provider', label: 'Provider' },
- { key: 'apiKey', label: 'API Key', type: 'password' },
- { key: 'endpoint', label: 'Endpoint URL' },
+ { key: 'provider', labelKey: 'channels.fields.provider' },
+ { key: 'apiKey', labelKey: 'channels.fields.apiKey', type: 'password' },
+ { key: 'endpoint', labelKey: 'channels.fields.endpoint' },
],
};
const ALL_CHANNEL_TYPES: ChannelType[] = ['push', 'sms', 'voice_call', 'email', 'telegram', 'wechat_work', 'voice_service'];
-const CHANNEL_LABELS: Record = {
- push: 'Push Notification',
- sms: 'SMS',
- voice_call: 'Voice Call',
- email: 'Email',
- telegram: 'Telegram',
- wechat_work: 'WeChat Work',
- voice_service: 'Voice Service',
+const CHANNEL_LABEL_KEYS: Record = {
+ push: 'channels.types.push',
+ sms: 'channels.types.sms',
+ voice_call: 'channels.types.voice',
+ email: 'channels.types.email',
+ telegram: 'channels.types.telegram',
+ wechat_work: 'channels.types.wechatWork',
+ voice_service: 'channels.types.voiceService',
};
const SEVERITY_OPTIONS = ['critical', 'high', 'medium', 'low', 'info'];
-const TABS = ['Channels', 'Contacts', 'Escalation Policies'] as const;
+const TABS = ['channels', 'contacts', 'escalationPolicies'] as const;
type Tab = (typeof TABS)[number];
+const TAB_LABEL_KEYS: Record = {
+ channels: 'tabs.channels',
+ contacts: 'tabs.contacts',
+ escalationPolicies: 'tabs.escalationPolicies',
+};
+
// ── Main Component ──────────────────────────────────────────────────────────
export default function CommunicationPage() {
- const [activeTab, setActiveTab] = useState('Channels');
+ const { t } = useTranslation('communication');
+ const [activeTab, setActiveTab] = useState('channels');
return (
-
Communication Settings
+
{t('title')}
{/* Tab bar */}
{TABS.map((tab) => (
@@ -127,14 +135,14 @@ export default function CommunicationPage() {
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30'
)}
>
- {tab}
+ {t(TAB_LABEL_KEYS[tab])}
))}
{/* Tab content */}
- {activeTab === 'Channels' &&
}
- {activeTab === 'Contacts' &&
}
- {activeTab === 'Escalation Policies' &&
}
+ {activeTab === 'channels' &&
}
+ {activeTab === 'contacts' &&
}
+ {activeTab === 'escalationPolicies' &&
}
);
}
@@ -142,6 +150,8 @@ export default function CommunicationPage() {
// ── Tab 1: Channels ─────────────────────────────────────────────────────────
function ChannelsTab() {
+ const { t } = useTranslation('communication');
+ const { t: tc } = useTranslation('common');
const queryClient = useQueryClient();
const [expandedId, setExpandedId] = useState(null);
@@ -170,19 +180,19 @@ function ChannelsTab() {
const channels = data?.data ?? [];
- if (isLoading) return Loading channels...
;
- if (error) return Error loading channels: {(error as Error).message}
;
+ if (isLoading) return {tc('loading')}
;
+ if (error) return {tc('error')}: {(error as Error).message}
;
return (
- {channels.length === 0 &&
No channels configured.
}
+ {channels.length === 0 &&
{tc('noData')}
}
{channels.map((ch) => (
{ch.name}
-
{CHANNEL_LABELS[ch.type] ?? ch.type}
+
{CHANNEL_LABEL_KEYS[ch.type] ? t(CHANNEL_LABEL_KEYS[ch.type]) : ch.type}
- {ch.configured ? 'Configured' : 'Not Configured'}
+ {ch.configured ? t('channels.configured') : t('channels.notConfigured')}
@@ -202,7 +212,7 @@ function ChannelsTab() {
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
ch.enabled ? 'bg-primary' : 'bg-muted'
)}
- title={ch.enabled ? 'Disable channel' : 'Enable channel'}
+ title={ch.enabled ? tc('disabled') : tc('enabled')}
>
setExpandedId(expandedId === ch.id ? null : ch.id)}
className="text-sm text-primary hover:underline"
>
- {expandedId === ch.id ? 'Hide Config' : 'Show Config'}
+ {expandedId === ch.id ? t('channels.hideConfig') : t('channels.showConfig')}
@@ -242,6 +252,8 @@ function ChannelConfigEditor({
onSave: (config: Record
) => void;
isSaving: boolean;
}) {
+ const { t } = useTranslation('communication');
+ const { t: tc } = useTranslation('common');
const fields = CHANNEL_CONFIG_FIELDS[channel.type] ?? [];
const [localConfig, setLocalConfig] = useState>({ ...channel.config });
@@ -249,7 +261,7 @@ function ChannelConfigEditor({
{fields.map((field) => (
- {field.label}
+ {t(field.labelKey)}
- {isSaving ? 'Saving...' : 'Save Configuration'}
+ {isSaving ? tc('saving') : t('channels.saveConfiguration')}
);
@@ -272,6 +284,8 @@ function ChannelConfigEditor({
// ── Tab 2: Contacts ─────────────────────────────────────────────────────────
function ContactsTab() {
+ const { t } = useTranslation('communication');
+ const { t: tc } = useTranslation('common');
const queryClient = useQueryClient();
const [showDialog, setShowDialog] = useState(false);
const [editingContact, setEditingContact] = useState
(null);
@@ -321,29 +335,29 @@ function ContactsTab() {
return (
-
{contacts.length} contact(s)
+
{t('contacts.count', { count: contacts.length })}
- Add Contact
+ {t('contacts.addContact')}
- {isLoading &&
Loading contacts...
}
- {error &&
Error: {(error as Error).message}
}
+ {isLoading &&
{tc('loading')}
}
+ {error &&
{tc('error')}: {(error as Error).message}
}
{!isLoading && !error && (
- Name
- Email
- Phone
- Role
- Channels
- Actions
+ {t('contacts.table.name')}
+ {t('contacts.table.email')}
+ {t('contacts.table.phone')}
+ {t('contacts.table.role')}
+ {t('contacts.table.channels')}
+ {t('contacts.table.actions')}
@@ -370,16 +384,16 @@ function ContactsTab() {
onClick={() => handleOpenEdit(c)}
className="text-xs text-primary hover:underline"
>
- Edit
+ {tc('edit')}
{
- if (confirm('Delete this contact?')) deleteMutation.mutate(c.id);
+ if (confirm(tc('confirmDelete'))) deleteMutation.mutate(c.id);
}}
className="text-xs text-red-500 hover:underline"
disabled={deleteMutation.isPending}
>
- Delete
+ {tc('delete')}
@@ -388,7 +402,7 @@ function ContactsTab() {
{contacts.length === 0 && (
- No contacts found. Click "Add Contact" to create one.
+ {t('contacts.empty')}
)}
@@ -430,6 +444,8 @@ function ContactDialog({
onSave: (data: Omit) => void;
isSaving: boolean;
}) {
+ const { t } = useTranslation('communication');
+ const { t: tc } = useTranslation('common');
const [name, setName] = useState(contact?.name ?? '');
const [email, setEmail] = useState(contact?.email ?? '');
const [phone, setPhone] = useState(contact?.phone ?? '');
@@ -448,26 +464,26 @@ function ContactDialog({
return (
-
{contact ? 'Edit Contact' : 'Add Contact'}
+
{contact ? t('contacts.editContact') : t('contacts.addContact')}