import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import axios from 'axios'; import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react'; import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider'; import { APP_ENDPOINTS } from '../../../config/env'; import { dashboardEn } from '../../../i18n/dashboard.en'; import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn'; import { pickLocale } from '../../../i18n'; import type { BotState } from '../../../types/bot'; import { WorkspaceEntriesList } from '../../dashboard/components/WorkspaceEntriesList'; import { WorkspaceHoverCard } from '../../dashboard/components/WorkspaceHoverCard'; import { WorkspacePreviewModal } from '../../dashboard/components/WorkspacePreviewModal'; import { useDashboardWorkspace } from '../../dashboard/hooks/useDashboardWorkspace'; import { formatBytes, formatWorkspaceTime } from '../../dashboard/utils'; import '../../dashboard/components/BotListPanel.css'; import '../../dashboard/components/RuntimePanel.css'; import '../../dashboard/components/DashboardShared.css'; import '../../dashboard/components/WorkspaceOverlay.css'; import '../../../components/ui/SharedUi.css'; interface PlatformBotRuntimeSectionProps { compactSheet?: boolean; isZh: boolean; pageSize: number; selectedBotInfo?: BotState; workspaceDownloadExtensions: string[]; } const ANSI_ESCAPE_RE = /(?:\u001b\[|\[)[0-9;]{1,12}m/g; const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40; const DOCKER_LOG_TABLE_ROW_HEIGHT = 56; const EMPTY_DOCKER_LOG_ENTRY = { key: '', index: '', level: '', text: '', tone: 'plain', } as const; function stripAnsi(textRaw: string) { return String(textRaw || '').replace(ANSI_ESCAPE_RE, '').trim(); } function parseDockerLogEntry(textRaw: string) { const text = stripAnsi(textRaw); const levelMatch = text.match(/\b(INFO|ERROR|WARN|WARNING|DEBUG|TRACE|CRITICAL|FATAL)\b/i); const levelRaw = String(levelMatch?.[1] || '').toUpperCase(); const level = levelRaw === 'WARNING' ? 'WARN' : (levelRaw || '-'); return { level, text, tone: level === 'ERROR' || level === 'FATAL' || level === 'CRITICAL' ? 'err' : level === 'WARN' ? 'warn' : level === 'INFO' ? 'info' : level === 'DEBUG' || level === 'TRACE' ? 'debug' : 'plain', } as const; } export function PlatformBotRuntimeSection({ compactSheet = false, isZh, pageSize, selectedBotInfo, workspaceDownloadExtensions, }: PlatformBotRuntimeSectionProps) { const { notify } = useLucentPrompt(); const dashboardT = pickLocale(isZh ? 'zh' : 'en', { 'zh-cn': dashboardZhCn, en: dashboardEn }); const [dockerLogs, setDockerLogs] = useState([]); const [dockerLogsLoading, setDockerLogsLoading] = useState(false); const [dockerLogsError, setDockerLogsError] = useState(''); const [dockerLogsPage, setDockerLogsPage] = useState(1); const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false); const dockerLogsCardRef = useRef(null); const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState(null); const workspaceSearchInputName = useMemo( () => `platform-workspace-search-${Math.random().toString(36).slice(2, 10)}`, [], ); const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10)); const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT; const recentLogEntries = useMemo(() => { const logs = (dockerLogs || []) .map((line) => String(line || '').trim()) .filter(Boolean) .map((line, index) => ({ key: `log-${dockerLogsPage}-${index}`, index: String((dockerLogsPage - 1) * effectivePageSize + index + 1).padStart(3, '0'), ...parseDockerLogEntry(line), })); if (logs.length > 0) return logs; const events = (selectedBotInfo?.events || []) .filter((event) => String(event?.text || '').trim().length > 0) .slice(0, effectivePageSize) .map((event, index) => ({ key: `event-${event.ts}-${index}`, index: String(index + 1).padStart(3, '0'), ...parseDockerLogEntry(`[${String(event.state || 'INFO').toUpperCase()}] ${String(event.text || '').trim()}`), })); return events; }, [dockerLogs, dockerLogsPage, effectivePageSize, selectedBotInfo?.events]); const dockerLogTableRows = useMemo( () => [ ...recentLogEntries, ...Array.from({ length: Math.max(0, effectivePageSize - recentLogEntries.length) }, (_, index) => ({ ...EMPTY_DOCKER_LOG_ENTRY, key: `docker-log-empty-${dockerLogsPage}-${index}`, })), ], [dockerLogsPage, effectivePageSize, recentLogEntries], ); const workspaceCardStyle = useMemo( () => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined), [compactSheet, workspaceCardHeightPx], ); const { closeWorkspacePreview, copyWorkspacePreviewPath, copyWorkspacePreviewUrl, filteredWorkspaceEntries, getWorkspaceDownloadHref, getWorkspaceRawHref, hideWorkspaceHoverCard, loadWorkspaceTree, openWorkspaceFilePreview, resetWorkspaceState, saveWorkspacePreviewMarkdown, setWorkspaceAutoRefresh, setWorkspacePreviewDraft, setWorkspacePreviewFullscreen, setWorkspacePreviewMode, setWorkspaceQuery, showWorkspaceHoverCard, workspaceAutoRefresh, workspaceCurrentPath, workspaceDownloadExtensionSet, workspaceError, workspaceFileLoading, workspaceFiles, workspaceHoverCard, workspaceLoading, workspaceParentPath, workspacePathDisplay, workspacePreview, workspacePreviewCanEdit, workspacePreviewDraft, workspacePreviewEditorEnabled, workspacePreviewFullscreen, workspacePreviewMarkdownComponents, workspacePreviewSaving, workspaceQuery, workspaceSearchLoading, } = useDashboardWorkspace({ selectedBotId: selectedBotInfo?.id || '', selectedBotDockerStatus: selectedBotInfo?.docker_status || '', workspaceDownloadExtensions, refreshAttachmentPolicy: async () => ({ uploadMaxMb: 0, allowedAttachmentExtensions: [], workspaceDownloadExtensions, }), notify, t: dashboardT, isZh, fileNotPreviewableLabel: dashboardT.fileNotPreviewable, }); useEffect(() => { if (!selectedBotInfo?.id) { resetWorkspaceState(); return; } resetWorkspaceState(); void loadWorkspaceTree(selectedBotInfo.id, ''); // Re-run only when the selected bot changes; loadWorkspaceTree is recreated // by workspace policy updates and would otherwise cause an initialization loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [resetWorkspaceState, selectedBotInfo?.id]); useEffect(() => { setDockerLogsPage(1); }, [selectedBotInfo?.id, effectivePageSize]); const fetchDockerLogsPage = useCallback(async (page: number, silent: boolean = false) => { if (!selectedBotInfo?.id) { setDockerLogs([]); setDockerLogsHasMore(false); setDockerLogsError(''); setDockerLogsLoading(false); return; } const safePage = Math.max(1, page); if (!silent) setDockerLogsLoading(true); setDockerLogsError(''); try { const res = await axios.get<{ bot_id: string; logs?: string[]; total?: number | null; offset?: number; limit?: number; has_more?: boolean; reverse?: boolean; }>(`${APP_ENDPOINTS.apiBase}/bots/${selectedBotInfo.id}/logs`, { params: { offset: (safePage - 1) * effectivePageSize, limit: effectivePageSize, reverse: true, }, }); const lines = Array.isArray(res.data?.logs) ? res.data.logs.map((line) => String(line || '').trim()).filter(Boolean) : []; setDockerLogs(lines); setDockerLogsHasMore(Boolean(res.data?.has_more)); setDockerLogsPage(safePage); } catch (error: any) { setDockerLogs([]); setDockerLogsHasMore(false); setDockerLogsError(error?.response?.data?.detail || (isZh ? '读取 Docker 日志失败。' : 'Failed to load Docker logs.')); } finally { if (!silent) { setDockerLogsLoading(false); } } }, [effectivePageSize, isZh, selectedBotInfo?.id]); useEffect(() => { if (!selectedBotInfo?.id) { setDockerLogs([]); setDockerLogsHasMore(false); setDockerLogsError(''); setDockerLogsLoading(false); return; } let stopped = false; void fetchDockerLogsPage(dockerLogsPage, false); if (dockerLogsPage !== 1 || String(selectedBotInfo.docker_status || '').toUpperCase() !== 'RUNNING') { return () => { stopped = true; }; } const timer = window.setInterval(() => { if (!stopped) { void fetchDockerLogsPage(1, true); } }, 3000); return () => { stopped = true; window.clearInterval(timer); }; }, [dockerLogsPage, fetchDockerLogsPage, selectedBotInfo?.docker_status, selectedBotInfo?.id]); useEffect(() => { if (compactSheet) { setWorkspaceCardHeightPx(null); return; } const cardEl = dockerLogsCardRef.current; if (!cardEl) return; const syncHeight = () => { const nextHeight = Math.round(cardEl.getBoundingClientRect().height); setWorkspaceCardHeightPx((current) => (current === nextHeight ? current : nextHeight)); }; syncHeight(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', syncHeight); return () => window.removeEventListener('resize', syncHeight); } const observer = new ResizeObserver(() => { syncHeight(); }); observer.observe(cardEl); window.addEventListener('resize', syncHeight); return () => { observer.disconnect(); window.removeEventListener('resize', syncHeight); }; }, [compactSheet, selectedBotInfo?.id]); return ( <>
{!compactSheet ?
{isZh ? 'Workspace' : 'Workspace'}
: null}
{dashboardT.workspaceHint}
{workspaceError ?
{workspaceError}
: null}
{workspacePathDisplay}
selectedBotInfo ? void loadWorkspaceTree(selectedBotInfo.id, workspaceCurrentPath) : undefined} tooltip={isZh ? '刷新工作区' : 'Refresh workspace'} aria-label={isZh ? '刷新工作区' : 'Refresh workspace'} >
setWorkspaceQuery('')} onSearchAction={() => setWorkspaceQuery(workspaceQuery.trim())} debounceMs={200} placeholder={dashboardT.workspaceSearchPlaceholder} ariaLabel={dashboardT.workspaceSearchPlaceholder} clearTitle={dashboardT.clearSearch} searchTitle={dashboardT.searchAction} name={workspaceSearchInputName} id={workspaceSearchInputName} disabled={!selectedBotInfo} />
{!selectedBotInfo ? (
{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}
) : workspaceLoading || workspaceSearchLoading ? (
{dashboardT.loadingDir}
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
{workspaceQuery.trim() ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
) : ( )}
{workspaceFileLoading ? dashboardT.openingPreview : dashboardT.workspaceHint}
{selectedBotInfo && !workspaceFiles.length ? (
{dashboardT.noPreviewFile}
) : null}
{!compactSheet ?
{isZh ? 'Docker Logs' : 'Docker Logs'}
: null}
{isZh ? '直接按页读取容器日志,按时间倒序展示;无日志时回退到最近运行事件' : 'Reading container logs page by page in reverse chronological order; falls back to runtime events if logs are unavailable'}
void fetchDockerLogsPage(dockerLogsPage, false)} tooltip={isZh ? '刷新 Docker Logs' : 'Refresh Docker Logs'} aria-label={isZh ? '刷新 Docker Logs' : 'Refresh Docker Logs'} >
{selectedBotInfo ? (
{dockerLogsError ?
{dockerLogsError}
: null}
{dockerLogTableRows.map((entry) => { const isPlaceholder = !entry.text; return ( ); })}
{isZh ? '序号' : 'No.'} {isZh ? '类型' : 'Level'} {isZh ? '内容' : 'Message'}
{entry.index || '\u00A0'} {entry.level ? ( {entry.level} ) : (   )} {entry.text || '\u00A0'}
{recentLogEntries.length === 0 ? (
{dockerLogsLoading ? (isZh ? '读取 Docker 日志中...' : 'Loading Docker logs...') : (isZh ? '暂无 Docker 日志或运行事件。' : 'No Docker logs or runtime events yet.')}
) : null}
) : (
{isZh ? '从左侧选择一个 Bot 查看日志。' : 'Select a bot from the list to view logs.'}
)} {selectedBotInfo ? (
{isZh ? `第 ${dockerLogsPage} 页${dockerLogsHasMore ? ' · 可继续加载更早日志' : ''}` : `Page ${dockerLogsPage}${dockerLogsHasMore ? ' · more older logs available' : ''}`}
void fetchDockerLogsPage(dockerLogsPage - 1, false)} tooltip={isZh ? '更新日志' : 'Newer logs'} aria-label={isZh ? '更新日志' : 'Newer logs'} > void fetchDockerLogsPage(dockerLogsPage + 1, false)} tooltip={isZh ? '更早日志' : 'Older logs'} aria-label={isZh ? '更早日志' : 'Older logs'} >
) : null}
setWorkspacePreviewFullscreen((value) => !value)} onCopyPreviewPath={copyWorkspacePreviewPath} onCopyPreviewUrl={copyWorkspacePreviewUrl} onPreviewDraftChange={setWorkspacePreviewDraft} onSavePreviewMarkdown={saveWorkspacePreviewMarkdown} onEnterEditMode={() => setWorkspacePreviewMode('edit')} onExitEditMode={() => { setWorkspacePreviewMode('preview'); setWorkspacePreviewDraft(workspacePreview?.content || ''); }} getWorkspaceDownloadHref={getWorkspaceDownloadHref} getWorkspaceRawHref={getWorkspaceRawHref} /> ); }