|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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('复制失败,请手动选择文本复制')); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
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]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
if (visible && deployment?.id) { |
|
|
fetchLogs(); |
|
|
} |
|
|
|
|
|
return () => { |
|
|
if (autoRefreshRef.current) { |
|
|
clearInterval(autoRefreshRef.current); |
|
|
} |
|
|
}; |
|
|
}, [visible, deployment?.id, streamFilter, selectedContainerId, following]); |
|
|
|
|
|
|
|
|
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; |
|
|
|