551 lines
22 KiB
TypeScript
551 lines
22 KiB
TypeScript
|
|
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: [],
|
||
|
|
}),
|
||
|
|
notify,
|
||
|
|
t: dashboardT,
|
||
|
|
isZh,
|
||
|
|
fileNotPreviewableLabel: dashboardT.fileNotPreviewable,
|
||
|
|
});
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedBotInfo?.id) {
|
||
|
|
resetWorkspaceState();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
resetWorkspaceState();
|
||
|
|
void loadWorkspaceTree(selectedBotInfo.id, '');
|
||
|
|
}, [loadWorkspaceTree, 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 (
|
||
|
|
<>
|
||
|
|
<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"> </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}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|