* fix: fix model deployment style issues, lint problems, and i18n gaps. * fix: adjust the key not to be displayed on the frontend, tested via the backend. * fix: adjust the sidebar configuration logic to use the default configuration items if they are not defined.
724 lines
23 KiB
JavaScript
724 lines
23 KiB
JavaScript
/*
|
|
Copyright (C) 2025 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
Modal,
|
|
Button,
|
|
Typography,
|
|
Select,
|
|
Input,
|
|
Space,
|
|
Spin,
|
|
Card,
|
|
Tag,
|
|
Empty,
|
|
Switch,
|
|
Divider,
|
|
Tooltip,
|
|
Radio,
|
|
} from '@douyinfe/semi-ui';
|
|
import {
|
|
FaCopy,
|
|
FaSearch,
|
|
FaClock,
|
|
FaTerminal,
|
|
FaServer,
|
|
FaInfoCircle,
|
|
FaLink,
|
|
} from 'react-icons/fa';
|
|
import { IconRefresh, IconDownload } from '@douyinfe/semi-icons';
|
|
import {
|
|
API,
|
|
showError,
|
|
showSuccess,
|
|
copy,
|
|
timestamp2string,
|
|
} from '../../../../helpers';
|
|
|
|
const { Text } = Typography;
|
|
|
|
const ALL_CONTAINERS = '__all__';
|
|
|
|
const ViewLogsModal = ({ visible, onCancel, deployment, t }) => {
|
|
const [logLines, setLogLines] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [following, setFollowing] = useState(false);
|
|
const [containers, setContainers] = useState([]);
|
|
const [containersLoading, setContainersLoading] = useState(false);
|
|
const [selectedContainerId, setSelectedContainerId] =
|
|
useState(ALL_CONTAINERS);
|
|
const [containerDetails, setContainerDetails] = useState(null);
|
|
const [containerDetailsLoading, setContainerDetailsLoading] = useState(false);
|
|
const [streamFilter, setStreamFilter] = useState('stdout');
|
|
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
|
|
|
|
const logContainerRef = useRef(null);
|
|
const autoRefreshRef = useRef(null);
|
|
|
|
// Auto scroll to bottom when new logs arrive
|
|
const scrollToBottom = () => {
|
|
if (logContainerRef.current) {
|
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
|
}
|
|
};
|
|
|
|
const resolveStreamValue = (value) => {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
if (value && typeof value.value === 'string') {
|
|
return value.value;
|
|
}
|
|
if (value && value.target && typeof value.target.value === 'string') {
|
|
return value.target.value;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const handleStreamChange = (value) => {
|
|
const next = resolveStreamValue(value) || 'stdout';
|
|
setStreamFilter(next);
|
|
};
|
|
|
|
const fetchLogs = async (containerIdOverride = undefined) => {
|
|
if (!deployment?.id) return;
|
|
|
|
const containerId =
|
|
typeof containerIdOverride === 'string'
|
|
? containerIdOverride
|
|
: selectedContainerId;
|
|
|
|
if (!containerId || containerId === ALL_CONTAINERS) {
|
|
setLogLines([]);
|
|
setLastUpdatedAt(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('container_id', containerId);
|
|
|
|
const streamValue = resolveStreamValue(streamFilter) || 'stdout';
|
|
if (streamValue && streamValue !== 'all') {
|
|
params.append('stream', streamValue);
|
|
}
|
|
if (following) params.append('follow', 'true');
|
|
|
|
const response = await API.get(
|
|
`/api/deployments/${deployment.id}/logs?${params}`,
|
|
);
|
|
|
|
if (response.data.success) {
|
|
const rawContent =
|
|
typeof response.data.data === 'string' ? response.data.data : '';
|
|
const normalized = rawContent.replace(/\r\n?/g, '\n');
|
|
const lines = normalized ? normalized.split('\n') : [];
|
|
|
|
setLogLines(lines);
|
|
setLastUpdatedAt(new Date());
|
|
|
|
setTimeout(scrollToBottom, 100);
|
|
}
|
|
} catch (error) {
|
|
showError(
|
|
t('获取日志失败') +
|
|
': ' +
|
|
(error.response?.data?.message || error.message),
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchContainers = async () => {
|
|
if (!deployment?.id) return;
|
|
|
|
setContainersLoading(true);
|
|
try {
|
|
const response = await API.get(
|
|
`/api/deployments/${deployment.id}/containers`,
|
|
);
|
|
|
|
if (response.data.success) {
|
|
const list = response.data.data?.containers || [];
|
|
setContainers(list);
|
|
|
|
setSelectedContainerId((current) => {
|
|
if (
|
|
current !== ALL_CONTAINERS &&
|
|
list.some((item) => item.container_id === current)
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
return list.length > 0 ? list[0].container_id : ALL_CONTAINERS;
|
|
});
|
|
|
|
if (list.length === 0) {
|
|
setContainerDetails(null);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showError(
|
|
t('获取容器列表失败') +
|
|
': ' +
|
|
(error.response?.data?.message || error.message),
|
|
);
|
|
} finally {
|
|
setContainersLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchContainerDetails = async (containerId) => {
|
|
if (!deployment?.id || !containerId || containerId === ALL_CONTAINERS) {
|
|
setContainerDetails(null);
|
|
return;
|
|
}
|
|
|
|
setContainerDetailsLoading(true);
|
|
try {
|
|
const response = await API.get(
|
|
`/api/deployments/${deployment.id}/containers/${containerId}`,
|
|
);
|
|
|
|
if (response.data.success) {
|
|
setContainerDetails(response.data.data || null);
|
|
}
|
|
} catch (error) {
|
|
showError(
|
|
t('获取容器详情失败') +
|
|
': ' +
|
|
(error.response?.data?.message || error.message),
|
|
);
|
|
} finally {
|
|
setContainerDetailsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleContainerChange = (value) => {
|
|
const newValue = value || ALL_CONTAINERS;
|
|
setSelectedContainerId(newValue);
|
|
setLogLines([]);
|
|
setLastUpdatedAt(null);
|
|
};
|
|
|
|
const refreshContainerDetails = () => {
|
|
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
|
|
fetchContainerDetails(selectedContainerId);
|
|
}
|
|
};
|
|
|
|
const renderContainerStatusTag = (status) => {
|
|
if (!status) {
|
|
return (
|
|
<Tag color='grey' size='small'>
|
|
{t('未知状态')}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
const normalized =
|
|
typeof status === 'string' ? status.trim().toLowerCase() : '';
|
|
const statusMap = {
|
|
running: { color: 'green', label: '运行中' },
|
|
pending: { color: 'orange', label: '准备中' },
|
|
deployed: { color: 'blue', label: '已部署' },
|
|
failed: { color: 'red', label: '失败' },
|
|
destroyed: { color: 'red', label: '已销毁' },
|
|
stopping: { color: 'orange', label: '停止中' },
|
|
terminated: { color: 'grey', label: '已终止' },
|
|
};
|
|
|
|
const config = statusMap[normalized] || { color: 'grey', label: status };
|
|
|
|
return (
|
|
<Tag color={config.color} size='small'>
|
|
{t(config.label)}
|
|
</Tag>
|
|
);
|
|
};
|
|
|
|
const currentContainer =
|
|
selectedContainerId !== ALL_CONTAINERS
|
|
? containers.find((ctr) => ctr.container_id === selectedContainerId)
|
|
: null;
|
|
|
|
const refreshLogs = () => {
|
|
if (selectedContainerId && selectedContainerId !== ALL_CONTAINERS) {
|
|
fetchContainerDetails(selectedContainerId);
|
|
}
|
|
fetchLogs();
|
|
};
|
|
|
|
const downloadLogs = () => {
|
|
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
|
if (sourceLogs.length === 0) {
|
|
showError(t('暂无日志可下载'));
|
|
return;
|
|
}
|
|
const logText = sourceLogs.join('\n');
|
|
|
|
const blob = new Blob([logText], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const safeContainerId =
|
|
selectedContainerId && selectedContainerId !== ALL_CONTAINERS
|
|
? selectedContainerId.replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
: '';
|
|
const fileName = safeContainerId
|
|
? `deployment-${deployment.id}-container-${safeContainerId}-logs.txt`
|
|
: `deployment-${deployment.id}-logs.txt`;
|
|
a.download = fileName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showSuccess(t('日志已下载'));
|
|
};
|
|
|
|
const copyAllLogs = async () => {
|
|
const sourceLogs = filteredLogs.length > 0 ? filteredLogs : logLines;
|
|
if (sourceLogs.length === 0) {
|
|
showError(t('暂无日志可复制'));
|
|
return;
|
|
}
|
|
const logText = sourceLogs.join('\n');
|
|
|
|
const copied = await copy(logText);
|
|
if (copied) {
|
|
showSuccess(t('日志已复制到剪贴板'));
|
|
} else {
|
|
showError(t('复制失败,请手动选择文本复制'));
|
|
}
|
|
};
|
|
|
|
// Auto refresh functionality
|
|
useEffect(() => {
|
|
if (autoRefresh && visible) {
|
|
autoRefreshRef.current = setInterval(() => {
|
|
fetchLogs();
|
|
}, 5000);
|
|
} else {
|
|
if (autoRefreshRef.current) {
|
|
clearInterval(autoRefreshRef.current);
|
|
autoRefreshRef.current = null;
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (autoRefreshRef.current) {
|
|
clearInterval(autoRefreshRef.current);
|
|
}
|
|
};
|
|
}, [autoRefresh, visible, selectedContainerId, streamFilter, following]);
|
|
|
|
useEffect(() => {
|
|
if (visible && deployment?.id) {
|
|
fetchContainers();
|
|
} else if (!visible) {
|
|
setContainers([]);
|
|
setSelectedContainerId(ALL_CONTAINERS);
|
|
setContainerDetails(null);
|
|
setStreamFilter('stdout');
|
|
setLogLines([]);
|
|
setLastUpdatedAt(null);
|
|
}
|
|
}, [visible, deployment?.id]);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setStreamFilter('stdout');
|
|
}
|
|
}, [selectedContainerId, visible]);
|
|
|
|
useEffect(() => {
|
|
if (visible && deployment?.id) {
|
|
fetchContainerDetails(selectedContainerId);
|
|
}
|
|
}, [visible, deployment?.id, selectedContainerId]);
|
|
|
|
// Initial load and cleanup
|
|
useEffect(() => {
|
|
if (visible && deployment?.id) {
|
|
fetchLogs();
|
|
}
|
|
|
|
return () => {
|
|
if (autoRefreshRef.current) {
|
|
clearInterval(autoRefreshRef.current);
|
|
}
|
|
};
|
|
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]);
|
|
|
|
// Filter logs based on search term
|
|
const filteredLogs = logLines
|
|
.map((line) => line ?? '')
|
|
.filter(
|
|
(line) =>
|
|
!searchTerm || line.toLowerCase().includes(searchTerm.toLowerCase()),
|
|
);
|
|
|
|
const renderLogEntry = (line, index) => (
|
|
<div
|
|
key={`${index}-${line.slice(0, 20)}`}
|
|
className='py-1 px-3 hover:bg-gray-50 font-mono text-sm border-b border-gray-100 whitespace-pre-wrap break-words'
|
|
>
|
|
{line}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
title={
|
|
<div className='flex items-center gap-2'>
|
|
<FaTerminal className='text-blue-500' />
|
|
<span>{t('容器日志')}</span>
|
|
<Text type='secondary' size='small'>
|
|
- {deployment?.container_name || deployment?.id}
|
|
</Text>
|
|
</div>
|
|
}
|
|
visible={visible}
|
|
onCancel={onCancel}
|
|
footer={null}
|
|
width={1000}
|
|
height={700}
|
|
className='logs-modal'
|
|
style={{ top: 20 }}
|
|
>
|
|
<div className='flex flex-col h-full max-h-[600px]'>
|
|
{/* Controls */}
|
|
<Card className='mb-4 border-0 shadow-sm'>
|
|
<div className='flex items-center justify-between flex-wrap gap-3'>
|
|
<Space wrap>
|
|
<Select
|
|
prefix={<FaServer />}
|
|
placeholder={t('选择容器')}
|
|
value={selectedContainerId}
|
|
onChange={handleContainerChange}
|
|
style={{ width: 240 }}
|
|
size='small'
|
|
loading={containersLoading}
|
|
dropdownStyle={{ maxHeight: 320, overflowY: 'auto' }}
|
|
>
|
|
<Select.Option value={ALL_CONTAINERS}>
|
|
{t('全部容器')}
|
|
</Select.Option>
|
|
{containers.map((ctr) => (
|
|
<Select.Option
|
|
key={ctr.container_id}
|
|
value={ctr.container_id}
|
|
>
|
|
<div className='flex flex-col'>
|
|
<span className='font-mono text-xs'>
|
|
{ctr.container_id}
|
|
</span>
|
|
<span className='text-xs text-gray-500'>
|
|
{ctr.brand_name || 'IO.NET'}
|
|
{ctr.hardware ? ` · ${ctr.hardware}` : ''}
|
|
</span>
|
|
</div>
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
|
|
<Input
|
|
prefix={<FaSearch />}
|
|
placeholder={t('搜索日志内容')}
|
|
value={searchTerm}
|
|
onChange={setSearchTerm}
|
|
style={{ width: 200 }}
|
|
size='small'
|
|
/>
|
|
|
|
<Space align='center' className='ml-2'>
|
|
<Text size='small' type='secondary'>
|
|
{t('日志流')}
|
|
</Text>
|
|
<Radio.Group
|
|
type='button'
|
|
size='small'
|
|
value={streamFilter}
|
|
onChange={handleStreamChange}
|
|
>
|
|
<Radio value='stdout'>STDOUT</Radio>
|
|
<Radio value='stderr'>STDERR</Radio>
|
|
</Radio.Group>
|
|
</Space>
|
|
|
|
<div className='flex items-center gap-2'>
|
|
<Switch
|
|
checked={autoRefresh}
|
|
onChange={setAutoRefresh}
|
|
size='small'
|
|
/>
|
|
<Text size='small'>{t('自动刷新')}</Text>
|
|
</div>
|
|
|
|
<div className='flex items-center gap-2'>
|
|
<Switch
|
|
checked={following}
|
|
onChange={setFollowing}
|
|
size='small'
|
|
/>
|
|
<Text size='small'>{t('跟随日志')}</Text>
|
|
</div>
|
|
</Space>
|
|
|
|
<Space>
|
|
<Tooltip content={t('刷新日志')}>
|
|
<Button
|
|
icon={<IconRefresh />}
|
|
onClick={refreshLogs}
|
|
loading={loading}
|
|
size='small'
|
|
theme='borderless'
|
|
/>
|
|
</Tooltip>
|
|
|
|
<Tooltip content={t('复制日志')}>
|
|
<Button
|
|
icon={<FaCopy />}
|
|
onClick={copyAllLogs}
|
|
size='small'
|
|
theme='borderless'
|
|
disabled={logLines.length === 0}
|
|
/>
|
|
</Tooltip>
|
|
|
|
<Tooltip content={t('下载日志')}>
|
|
<Button
|
|
icon={<IconDownload />}
|
|
onClick={downloadLogs}
|
|
size='small'
|
|
theme='borderless'
|
|
disabled={logLines.length === 0}
|
|
/>
|
|
</Tooltip>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* Status Info */}
|
|
<Divider margin='12px' />
|
|
<div className='flex items-center justify-between'>
|
|
<Space size='large'>
|
|
<Text size='small' type='secondary'>
|
|
{t('共 {{count}} 条日志', { count: logLines.length })}
|
|
</Text>
|
|
{searchTerm && (
|
|
<Text size='small' type='secondary'>
|
|
{t('(筛选后显示 {{count}} 条)', {
|
|
count: filteredLogs.length,
|
|
})}
|
|
</Text>
|
|
)}
|
|
{autoRefresh && (
|
|
<Tag color='green' size='small'>
|
|
<FaClock className='mr-1' />
|
|
{t('自动刷新中')}
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
|
|
<Text size='small' type='secondary'>
|
|
{t('状态')}: {deployment?.status || 'unknown'}
|
|
</Text>
|
|
</div>
|
|
|
|
{selectedContainerId !== ALL_CONTAINERS && (
|
|
<>
|
|
<Divider margin='12px' />
|
|
<div className='flex flex-col gap-3'>
|
|
<div className='flex items-center justify-between flex-wrap gap-2'>
|
|
<Space>
|
|
<Tag color='blue' size='small'>
|
|
{t('容器')}
|
|
</Tag>
|
|
<Text className='font-mono text-xs'>
|
|
{selectedContainerId}
|
|
</Text>
|
|
{renderContainerStatusTag(
|
|
containerDetails?.status || currentContainer?.status,
|
|
)}
|
|
</Space>
|
|
|
|
<Space>
|
|
{containerDetails?.public_url && (
|
|
<Tooltip content={containerDetails.public_url}>
|
|
<Button
|
|
icon={<FaLink />}
|
|
size='small'
|
|
theme='borderless'
|
|
onClick={() =>
|
|
window.open(containerDetails.public_url, '_blank')
|
|
}
|
|
/>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip content={t('刷新容器信息')}>
|
|
<Button
|
|
icon={<IconRefresh />}
|
|
onClick={refreshContainerDetails}
|
|
size='small'
|
|
theme='borderless'
|
|
loading={containerDetailsLoading}
|
|
/>
|
|
</Tooltip>
|
|
</Space>
|
|
</div>
|
|
|
|
{containerDetailsLoading ? (
|
|
<div className='flex items-center justify-center py-6'>
|
|
<Spin tip={t('加载容器详情中...')} />
|
|
</div>
|
|
) : containerDetails ? (
|
|
<div className='grid gap-4 md:grid-cols-2 text-sm'>
|
|
<div className='flex items-center gap-2'>
|
|
<FaInfoCircle className='text-blue-500' />
|
|
<Text type='secondary'>{t('硬件')}</Text>
|
|
<Text>
|
|
{containerDetails?.brand_name ||
|
|
currentContainer?.brand_name ||
|
|
t('未知品牌')}
|
|
{containerDetails?.hardware ||
|
|
currentContainer?.hardware
|
|
? ` · ${containerDetails?.hardware || currentContainer?.hardware}`
|
|
: ''}
|
|
</Text>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<FaServer className='text-purple-500' />
|
|
<Text type='secondary'>{t('GPU/容器')}</Text>
|
|
<Text>
|
|
{containerDetails?.gpus_per_container ??
|
|
currentContainer?.gpus_per_container ??
|
|
0}
|
|
</Text>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<FaClock className='text-orange-500' />
|
|
<Text type='secondary'>{t('创建时间')}</Text>
|
|
<Text>
|
|
{containerDetails?.created_at
|
|
? timestamp2string(containerDetails.created_at)
|
|
: currentContainer?.created_at
|
|
? timestamp2string(currentContainer.created_at)
|
|
: t('未知')}
|
|
</Text>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<FaInfoCircle className='text-green-500' />
|
|
<Text type='secondary'>{t('运行时长')}</Text>
|
|
<Text>
|
|
{containerDetails?.uptime_percent ??
|
|
currentContainer?.uptime_percent ??
|
|
0}
|
|
%
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Text size='small' type='secondary'>
|
|
{t('暂无容器详情')}
|
|
</Text>
|
|
)}
|
|
|
|
{containerDetails?.events &&
|
|
containerDetails.events.length > 0 && (
|
|
<div className='bg-gray-50 rounded-lg p-3'>
|
|
<Text size='small' type='secondary'>
|
|
{t('最近事件')}
|
|
</Text>
|
|
<div className='mt-2 space-y-2 max-h-32 overflow-y-auto'>
|
|
{containerDetails.events
|
|
.slice(0, 5)
|
|
.map((event, index) => (
|
|
<div
|
|
key={`${event.time}-${index}`}
|
|
className='flex gap-3 text-xs font-mono'
|
|
>
|
|
<span className='text-gray-500'>
|
|
{event.time
|
|
? timestamp2string(event.time)
|
|
: '--'}
|
|
</span>
|
|
<span className='text-gray-700 break-all flex-1'>
|
|
{event.message}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Log Content */}
|
|
<div className='flex-1 flex flex-col border rounded-lg bg-gray-50 overflow-hidden'>
|
|
<div
|
|
ref={logContainerRef}
|
|
className='flex-1 overflow-y-auto bg-white'
|
|
style={{ maxHeight: '400px' }}
|
|
>
|
|
{loading && logLines.length === 0 ? (
|
|
<div className='flex items-center justify-center p-8'>
|
|
<Spin tip={t('加载日志中...')} />
|
|
</div>
|
|
) : filteredLogs.length === 0 ? (
|
|
<Empty
|
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
description={
|
|
searchTerm ? t('没有匹配的日志条目') : t('暂无日志')
|
|
}
|
|
style={{ padding: '60px 20px' }}
|
|
/>
|
|
) : (
|
|
<div>
|
|
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer status */}
|
|
{logLines.length > 0 && (
|
|
<div className='flex items-center justify-between px-3 py-2 bg-gray-50 border-t text-xs text-gray-500'>
|
|
<span>{following ? t('正在跟随最新日志') : t('日志已加载')}</span>
|
|
<span>
|
|
{t('最后更新')}:{' '}
|
|
{lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default ViewLogsModal;
|