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

439 lines
19 KiB
TypeScript

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, RefreshCw, Terminal } from 'lucide-react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/useLucentPrompt';
import { dashboardEn } from '../../../i18n/dashboard.en';
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
import { pickLocale } from '../../../i18n';
import { WorkspaceEntriesList } from '../../../shared/workspace/WorkspaceEntriesList';
import { WorkspaceHoverCard } from '../../../shared/workspace/WorkspaceHoverCard';
import { WorkspacePreviewModal } from '../../../shared/workspace/WorkspacePreviewModal';
import '../../../shared/workspace/WorkspaceOverlay.css';
import { formatBytes, formatWorkspaceTime, isPreviewableWorkspaceFile } from '../../../shared/workspace/utils';
import { useBotWorkspace } from '../../../shared/workspace/useBotWorkspace';
import type { BotState } from '../../../types/bot';
import { usePlatformBotDockerLogs } from '../hooks/usePlatformBotDockerLogs';
import '../../dashboard/components/BotListPanel.css';
import '../../dashboard/components/RuntimePanel.css';
import '../../dashboard/components/DashboardShared.css';
import '../../../components/ui/SharedUi.css';
interface PlatformBotRuntimeSectionProps {
compactSheet?: boolean;
isZh: boolean;
pageSize: number;
selectedBotInfo?: BotState;
workspaceDownloadExtensions: string[];
}
const DOCKER_LOG_TABLE_HEADER_HEIGHT = 40;
const DOCKER_LOG_TABLE_ROW_HEIGHT = 56;
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 dockerLogsCardRef = useRef<HTMLDivElement | null>(null);
const [workspaceCardHeightPx, setWorkspaceCardHeightPx] = useState<number | null>(null);
const workspaceSearchInputName = `platform-workspace-search-${useId().replace(/:/g, '-')}`;
const effectivePageSize = Math.max(1, Math.trunc(pageSize || 10));
const dockerLogsTableHeightPx = DOCKER_LOG_TABLE_HEADER_HEIGHT + effectivePageSize * DOCKER_LOG_TABLE_ROW_HEIGHT;
const workspaceCardStyle = useMemo(
() => (!compactSheet && workspaceCardHeightPx ? { height: workspaceCardHeightPx } : undefined),
[compactSheet, workspaceCardHeightPx],
);
const refreshWorkspaceAttachmentPolicy = useCallback(
async () => ({
uploadMaxMb: 0,
allowedAttachmentExtensions: [],
workspaceDownloadExtensions,
}),
[workspaceDownloadExtensions],
);
const {
closeWorkspacePreview,
copyWorkspacePreviewPath,
copyWorkspacePreviewUrl,
filteredWorkspaceEntries,
getWorkspaceDownloadHref,
getWorkspaceRawHref,
hideWorkspaceHoverCard,
loadWorkspaceTree,
openWorkspaceFilePreview,
resetWorkspaceState,
saveWorkspacePreviewMarkdown,
setWorkspaceAutoRefresh,
setWorkspacePreviewDraft,
setWorkspacePreviewFullscreen,
setWorkspacePreviewMode,
setWorkspaceQuery,
showWorkspaceHoverCard,
workspaceAutoRefresh,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
workspaceError,
workspaceFileLoading,
workspaceHoverCard,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspacePreview,
workspacePreviewCanEdit,
workspacePreviewDraft,
workspacePreviewEditorEnabled,
workspacePreviewFullscreen,
workspacePreviewMarkdownComponents,
workspacePreviewSaving,
workspaceQuery,
workspaceSearchLoading,
} = useBotWorkspace({
selectedBotId: selectedBotInfo?.id || '',
selectedBotDockerStatus: selectedBotInfo?.docker_status || '',
workspaceDownloadExtensions,
refreshAttachmentPolicy: refreshWorkspaceAttachmentPolicy,
notify,
t: dashboardT,
isZh,
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
});
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
const hasVisibleWorkspaceEntries = filteredWorkspaceEntries.length > 0;
const visibleWorkspaceFiles = filteredWorkspaceEntries.filter((entry) => entry.type === 'file');
const hasVisiblePreviewableFiles = visibleWorkspaceFiles.some((entry) =>
isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet),
);
const showWorkspaceEmptyState = !workspaceLoading && !workspaceSearchLoading && !workspaceError && !hasVisibleWorkspaceEntries;
const showNoPreviewableFilesHint = Boolean(
selectedBotInfo &&
!workspaceError &&
!normalizedWorkspaceQuery &&
visibleWorkspaceFiles.length > 0 &&
!hasVisiblePreviewableFiles,
);
const {
dockerLogsError,
dockerLogsHasMore,
dockerLogsLoading,
dockerLogsPage,
dockerLogTableRows,
fetchDockerLogsPage,
recentLogEntries,
} = usePlatformBotDockerLogs({
effectivePageSize,
isZh,
selectedBotInfo,
});
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(() => {
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>
) : (
<>
{(workspaceParentPath !== null || hasVisibleWorkspaceEntries) ? (
<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}
/>
) : null}
{showWorkspaceEmptyState ? (
<div className="ops-empty-inline">
{normalizedWorkspaceQuery ? dashboardT.workspaceSearchNoResult : dashboardT.emptyDir}
</div>
) : null}
</>
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading ? dashboardT.openingPreview : dashboardT.workspaceHint}
</div>
</div>
{showNoPreviewableFilesHint ? (
<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}
/>
</>
);
}