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