dashboard-nanobot/frontend/src/modules/platform/components/PlatformBotRuntimeSection.tsx

555 lines
22 KiB
TypeScript
Raw Normal View History

2026-03-31 04:31:47 +00:00
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<string[]>([]);
const [dockerLogsLoading, setDockerLogsLoading] = useState(false);
const [dockerLogsError, setDockerLogsError] = useState('');
const [dockerLogsPage, setDockerLogsPage] = useState(1);
const [dockerLogsHasMore, setDockerLogsHasMore] = useState(false);
const dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(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: [],
2026-04-02 04:14:08 +00:00
workspaceDownloadExtensions,
2026-03-31 04:31:47 +00:00
}),
notify,
t: dashboardT,
isZh,
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
});
useEffect(() => {
if (!selectedBotInfo?.id) {
resetWorkspaceState();
return;
}
resetWorkspaceState();
void loadWorkspaceTree(selectedBotInfo.id, '');
2026-04-02 04:14:08 +00:00
// 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]);
2026-03-31 04:31:47 +00:00
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 (
<>
<div className={`platform-bot-runtime-stack ${compactSheet ? 'is-compact' : ''}`}>
<section className={`${compactSheet ? 'platform-compact-overview' : 'panel stack'} platform-bot-runtime-section`}>
{!compactSheet ? <div className="platform-bot-runtime-title">{isZh ? 'Workspace' : 'Workspace'}</div> : null}
<div className="card platform-bot-runtime-card platform-workspace-card" style={workspaceCardStyle}>
<div className="platform-bot-runtime-card-head">
<div>
<div className="platform-monitor-meta">{dashboardT.workspaceHint}</div>
</div>
</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar">
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading || !selectedBotInfo}
onClick={() => selectedBotInfo ? void loadWorkspaceTree(selectedBotInfo.id, workspaceCurrentPath) : undefined}
tooltip={isZh ? '刷新工作区' : 'Refresh workspace'}
aria-label={isZh ? '刷新工作区' : 'Refresh workspace'}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={dashboardT.autoRefresh}>
<span className="workspace-auto-switch-label">{dashboardT.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={() => setWorkspaceAutoRefresh((value) => !value)}
aria-label={dashboardT.autoRefresh}
disabled={!selectedBotInfo}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div>
<div className="workspace-search-toolbar">
<ProtectedSearchInput
value={workspaceQuery}
onChange={setWorkspaceQuery}
onClear={() => 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}
/>
</div>
<div className="workspace-panel">
<div className="workspace-list">
{!selectedBotInfo ? (
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看工作区。' : 'Select a bot from the list to view its workspace.'}</div>
) : workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{dashboardT.loadingDir}</div>
) : filteredWorkspaceEntries.length === 0 && workspaceParentPath === null ? (
<div className="ops-empty-inline">
{workspaceQuery.trim() ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
</div>
) : (
<WorkspaceEntriesList
nodes={filteredWorkspaceEntries}
workspaceParentPath={workspaceParentPath}
selectedBotId={selectedBotInfo.id}
workspaceFileLoading={workspaceFileLoading}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
labels={{
download: dashboardT.download,
fileNotPreviewable: dashboardT.fileNotPreviewable,
folder: dashboardT.folder,
goUp: dashboardT.goUp,
goUpTitle: dashboardT.goUpTitle,
openFolderTitle: dashboardT.openFolderTitle,
previewTitle: dashboardT.previewTitle,
}}
onLoadWorkspaceTree={loadWorkspaceTree}
onOpenWorkspaceFilePreview={openWorkspaceFilePreview}
onShowWorkspaceHoverCard={showWorkspaceHoverCard}
onHideWorkspaceHoverCard={hideWorkspaceHoverCard}
/>
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading ? dashboardT.openingPreview : dashboardT.workspaceHint}
</div>
</div>
{selectedBotInfo && !workspaceFiles.length ? (
<div className="ops-empty-inline">{dashboardT.noPreviewFile}</div>
) : null}
</div>
</section>
<section className={`${compactSheet ? 'platform-compact-overview' : 'panel stack'} platform-bot-runtime-section`}>
{!compactSheet ? <div className="platform-bot-runtime-title">{isZh ? 'Docker Logs' : 'Docker Logs'}</div> : null}
<div ref={dockerLogsCardRef} className="card platform-bot-runtime-card platform-docker-logs-card">
<div className="platform-bot-runtime-card-head">
<div>
<div className="platform-monitor-meta">
{isZh ? '直接按页读取容器日志,按时间倒序展示;无日志时回退到最近运行事件' : 'Reading container logs page by page in reverse chronological order; falls back to runtime events if logs are unavailable'}
</div>
</div>
<div className="platform-bot-runtime-card-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={dockerLogsLoading || !selectedBotInfo}
onClick={() => void fetchDockerLogsPage(dockerLogsPage, false)}
tooltip={isZh ? '刷新 Docker Logs' : 'Refresh Docker Logs'}
aria-label={isZh ? '刷新 Docker Logs' : 'Refresh Docker Logs'}
>
<RefreshCw size={14} className={dockerLogsLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<Terminal size={16} />
</div>
</div>
{selectedBotInfo ? (
<div className="platform-docker-logs-table-shell">
{dockerLogsError ? <div className="ops-empty-inline">{dockerLogsError}</div> : null}
<div className="platform-docker-logs-table-wrap" style={{ height: dockerLogsTableHeightPx }}>
<table className="table platform-docker-logs-table">
<thead>
<tr>
<th>{isZh ? '序号' : 'No.'}</th>
<th>{isZh ? '类型' : 'Level'}</th>
<th>{isZh ? '内容' : 'Message'}</th>
</tr>
</thead>
<tbody>
{dockerLogTableRows.map((entry) => {
const isPlaceholder = !entry.text;
return (
<tr
key={entry.key}
className={isPlaceholder ? 'platform-docker-log-row is-placeholder' : 'platform-docker-log-row'}
aria-hidden={isPlaceholder ? 'true' : undefined}
>
<td className="mono platform-docker-log-index">{entry.index || '\u00A0'}</td>
<td>
{entry.level ? (
<span className={`platform-docker-log-level tone-${entry.tone}`}>{entry.level}</span>
) : (
<span className="platform-docker-log-placeholder">&nbsp;</span>
)}
</td>
<td className={`platform-docker-log-text tone-${entry.tone}`}>{entry.text || '\u00A0'}</td>
</tr>
);
})}
</tbody>
</table>
{recentLogEntries.length === 0 ? (
<div className="ops-empty-inline platform-docker-logs-empty">
{dockerLogsLoading
? (isZh ? '读取 Docker 日志中...' : 'Loading Docker logs...')
: (isZh ? '暂无 Docker 日志或运行事件。' : 'No Docker logs or runtime events yet.')}
</div>
) : null}
</div>
</div>
) : (
<div className="ops-empty-inline">{isZh ? '从左侧选择一个 Bot 查看日志。' : 'Select a bot from the list to view logs.'}</div>
)}
{selectedBotInfo ? (
<div className="platform-usage-pager platform-docker-logs-pager">
<span className="pager-status">
{isZh
? `${dockerLogsPage}${dockerLogsHasMore ? ' · 可继续加载更早日志' : ''}`
: `Page ${dockerLogsPage}${dockerLogsHasMore ? ' · more older logs available' : ''}`}
</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={dockerLogsPage <= 1 || dockerLogsLoading}
onClick={() => void fetchDockerLogsPage(dockerLogsPage - 1, false)}
tooltip={isZh ? '更新日志' : 'Newer logs'}
aria-label={isZh ? '更新日志' : 'Newer logs'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={!dockerLogsHasMore || dockerLogsLoading}
onClick={() => void fetchDockerLogsPage(dockerLogsPage + 1, false)}
tooltip={isZh ? '更早日志' : 'Older logs'}
aria-label={isZh ? '更早日志' : 'Older logs'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
) : null}
</div>
</section>
</div>
<WorkspacePreviewModal
isZh={isZh}
labels={{
cancel: dashboardT.cancel,
close: dashboardT.close,
copyAddress: dashboardT.copyAddress,
download: dashboardT.download,
editFile: dashboardT.editFile,
filePreview: dashboardT.filePreview,
fileTruncated: dashboardT.fileTruncated,
save: dashboardT.save,
}}
preview={workspacePreview}
previewFullscreen={workspacePreviewFullscreen}
previewEditorEnabled={workspacePreviewEditorEnabled}
previewCanEdit={workspacePreviewCanEdit}
previewDraft={workspacePreviewDraft}
previewSaving={workspacePreviewSaving}
markdownComponents={workspacePreviewMarkdownComponents}
onClose={closeWorkspacePreview}
onToggleFullscreen={() => setWorkspacePreviewFullscreen((value) => !value)}
onCopyPreviewPath={copyWorkspacePreviewPath}
onCopyPreviewUrl={copyWorkspacePreviewUrl}
onPreviewDraftChange={setWorkspacePreviewDraft}
onSavePreviewMarkdown={saveWorkspacePreviewMarkdown}
onEnterEditMode={() => setWorkspacePreviewMode('edit')}
onExitEditMode={() => {
setWorkspacePreviewMode('preview');
setWorkspacePreviewDraft(workspacePreview?.content || '');
}}
getWorkspaceDownloadHref={getWorkspaceDownloadHref}
getWorkspaceRawHref={getWorkspaceRawHref}
/>
<WorkspaceHoverCard
state={workspaceHoverCard}
isZh={isZh}
formatWorkspaceTime={formatWorkspaceTime}
formatBytes={formatBytes}
/>
</>
);
}