2026-03-31 04:31:47 +00:00
import { useCallback , useEffect , 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/LucentPromptProvider' ;
import { dashboardEn } from '../../../i18n/dashboard.en' ;
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn' ;
import { pickLocale } from '../../../i18n' ;
2026-04-03 15:00:08 +00:00
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' ;
2026-03-31 04:31:47 +00:00
import type { BotState } from '../../../types/bot' ;
2026-04-03 15:00:08 +00:00
import { usePlatformBotDockerLogs } from '../hooks/usePlatformBotDockerLogs' ;
2026-03-31 04:31:47 +00:00
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 = 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 workspaceCardStyle = useMemo (
( ) = > ( ! compactSheet && workspaceCardHeightPx ? { height : workspaceCardHeightPx } : undefined ) ,
[ compactSheet , workspaceCardHeightPx ] ,
) ;
2026-04-03 15:00:08 +00:00
const refreshWorkspaceAttachmentPolicy = useCallback (
async ( ) = > ( {
uploadMaxMb : 0 ,
allowedAttachmentExtensions : [ ] ,
workspaceDownloadExtensions ,
} ) ,
[ workspaceDownloadExtensions ] ,
) ;
2026-03-31 04:31:47 +00:00
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 ,
2026-04-03 15:00:08 +00:00
} = useBotWorkspace ( {
2026-03-31 04:31:47 +00:00
selectedBotId : selectedBotInfo?.id || '' ,
selectedBotDockerStatus : selectedBotInfo?.docker_status || '' ,
workspaceDownloadExtensions ,
2026-04-03 15:00:08 +00:00
refreshAttachmentPolicy : refreshWorkspaceAttachmentPolicy ,
2026-03-31 04:31:47 +00:00
notify ,
t : dashboardT ,
isZh ,
fileNotPreviewableLabel : dashboardT.fileNotPreviewable ,
} ) ;
2026-04-03 15:00:08 +00:00
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 ,
} ) ;
2026-03-31 04:31:47 +00:00
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 ( ( ) = > {
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 >
) : (
2026-04-03 15:00:08 +00:00
< >
{ ( 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 }
< / >
2026-03-31 04:31:47 +00:00
) }
< / div >
< div className = "workspace-hint" >
{ workspaceFileLoading ? dashboardT.openingPreview : dashboardT.workspaceHint }
< / div >
< / div >
2026-04-03 15:00:08 +00:00
{ showNoPreviewableFilesHint ? (
2026-03-31 04:31:47 +00:00
< 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 }
/ >
< / >
) ;
}