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 }
/ >
< / >
) ;
}