439 lines
19 KiB
TypeScript
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"> </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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|