/* 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 . 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 ( {t('未知状态')} ); } 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 ( {t(config.label)} ); }; 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) => (
{line}
); return ( {t('容器日志')} - {deployment?.container_name || deployment?.id} } visible={visible} onCancel={onCancel} footer={null} width={1000} height={700} className="logs-modal" style={{ top: 20 }} >
{/* Controls */}
} placeholder={t('搜索日志内容')} value={searchTerm} onChange={setSearchTerm} style={{ width: 200 }} size="small" /> {t('日志流')} STDOUT STDERR
{t('自动刷新')}
{t('跟随日志')}
{/* Status Info */}
{t('共 {{count}} 条日志', { count: logLines.length })} {searchTerm && ( {t('(筛选后显示 {{count}} 条)', { count: filteredLogs.length })} )} {autoRefresh && ( {t('自动刷新中')} )} {t('状态')}: {deployment?.status || 'unknown'}
{selectedContainerId !== ALL_CONTAINERS && ( <>
{t('容器')} {selectedContainerId} {renderContainerStatusTag(containerDetails?.status || currentContainer?.status)} {containerDetails?.public_url && (
{containerDetailsLoading ? (
) : containerDetails ? (
{t('硬件')} {containerDetails?.brand_name || currentContainer?.brand_name || t('未知品牌')} {(containerDetails?.hardware || currentContainer?.hardware) ? ` · ${containerDetails?.hardware || currentContainer?.hardware}` : ''}
{t('GPU/容器')} {containerDetails?.gpus_per_container ?? currentContainer?.gpus_per_container ?? 0}
{t('创建时间')} {containerDetails?.created_at ? timestamp2string(containerDetails.created_at) : currentContainer?.created_at ? timestamp2string(currentContainer.created_at) : t('未知')}
{t('运行时长')} {containerDetails?.uptime_percent ?? currentContainer?.uptime_percent ?? 0}%
) : ( {t('暂无容器详情')} )} {containerDetails?.events && containerDetails.events.length > 0 && (
{t('最近事件')}
{containerDetails.events.slice(0, 5).map((event, index) => (
{event.time ? timestamp2string(event.time) : '--'} {event.message}
))}
)}
)}
{/* Log Content */}
{loading && logLines.length === 0 ? (
) : filteredLogs.length === 0 ? ( ) : (
{filteredLogs.map((log, index) => renderLogEntry(log, index))}
)}
{/* Footer status */} {logLines.length > 0 && (
{following ? t('正在跟随最新日志') : t('日志已加载')} {t('最后更新')}: {lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : '--'}
)}
); }; export default ViewLogsModal;