| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|