@@ -1,30 +1,29 @@
import { ListChecks , Pause , Play , RotateCw , Send , Sparkles , Terminal , Trash2 , X } from "lucide-react" ;
import { type FormEvent , type KeyboardEvent as ReactKeyboardEvent , type ReactNode , useCallback , useEffect , useMemo , useState } from "react" ;
import { type FormEvent , type KeyboardEvent as ReactKeyboardEvent , type ReactNode , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import { platformApiClient } from "../api/client" ;
import type { LogEntryBody , LogStreamResponse } from "../api/types" ;
import { sourceRCONRawCommandRequest } from "../schemas/source Rcon" ;
import type { GameClientBridgeCommandResponse , LogEntryBody , LogStreamResponse } from "../api/types" ;
import { scumManagementRCONCommandRequest } from "../schemas/scumManagement Rcon" ;
import { cx } from "../utils/classes" ;
import { appendLiveLogEntries , entryFromServerLogEvent , mergeLogStreams , parseLogStreamEvent , parseServerLogEvent , streamFromServerLogEvent , type LiveLogEntry } from "../utils/logEvents" ;
import { EmptyState , ErrorState , LoadingState , ResultBadge } from "./StateViews" ;
type LoadState < T > = { status : "loading" } | { status : "error" ; reason : string } | { status : "ready" ; data : T } ;
type LiveLogEntry = LogEntryBody & { source : string ; streamId : string ; streamKey : string } ;
type TerminalLine = { id : string ; tone : "input" | "info" | "success" | "warn" | "error" ; text : string ; at : string ; sortKey : number ; streamKey? : string ; level? : string ; seq? : number } ;
type TerminalQuickCommand = { label : string ; command : string ; hint : string } ;
const liveLogPollMs = 1000 ;
const terminalLogPollMs = 1000 ;
const logStreamPollMs = 5000 ;
const terminalBridgeResultPollMs = 1000 ;
const terminalBridgeResultPollAttempts = 30 ;
const liveLogHistoryWindow = 100 ;
const terminalLogQueryLimit = 150 ;
const terminalHistoryWindow = 150 ;
const maxLogEntries = 500 ;
const maxTerminalLines = 600 ;
const terminalQuickCommandCatalog : Record < string , TerminalQuickCommand [ ] > = {
"game.scum" : [
{ label : "查询玩家" , command : "ListPlayers" , hint : "SCUM RCON " } ,
{ label : "服务器信息 " , command : "ServerInfo " , hint : "SCUM RCON " } ,
{ label : "设为中午 " , command : "SetTime 12 " , hint : "SCUM RCON " }
{ label : "查询玩家" , command : "# ListPlayers" , hint : "在线玩家列表 " } ,
{ label : "查询队伍 " , command : "#ListSquads 1 " , hint : "服务器队伍列表 " } ,
{ label : "查询车辆 " , command : "#ListSpawnedVehicles true " , hint : "已生成车辆列表 " } ,
{ label : "设为中午" , command : "#SetTime 12" , hint : "设置游戏时间" }
]
} ;
@@ -85,10 +84,15 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
const [ streams , setStreams ] = useState < LoadState < LogStreamResponse [ ] > > ( { status : "loading" } ) ;
const [ selectedStreamId , setSelectedStreamId ] = useState ( "" ) ;
const [ entries , setEntries ] = useState < LiveLogEntry [ ] > ( [ ] ) ;
const [ cursorByStream , setCursorByStream ] = useState < Record < string , number > > ( { } ) ;
const [ paused , setPaused ] = useState ( false ) ;
const [ keyword , setKeyword ] = useState ( "" ) ;
const [ lastRefreshAt , setLastRefreshAt ] = useState ( "" ) ;
const [ eventSourceKey , setEventSourceKey ] = useState ( 0 ) ;
const pausedRef = useRef ( paused ) ;
useEffect ( ( ) = > {
pausedRef . current = paused ;
} , [ paused ] ) ;
const loadStreams = useCallback ( async ( showLoading = true ) = > {
if ( ! open ) return ;
@@ -105,40 +109,45 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
useEffect ( ( ) = > {
if ( ! open ) return ;
setEntries ( [ ] ) ;
setCursorByStream ( { } ) ;
setPaused ( false ) ;
setLastRefreshAt ( "" ) ;
void loadStreams ( ) ;
} , [ loadStreams , open ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return undefined ;
const timer = window . setInterval ( ( ) = > void loadStreams ( false ) . catch ( ( ) = > undefined ) , logStreamPollMs ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ loadStreams , open ] ) ;
let ready = false ;
const events = platformApiClient . openServerLogEvents ( serverId , { historyLimit : liveLogHistoryWindow } ) ;
events . addEventListener ( "open" , ( ) = > setLastRefreshAt ( new Date ( ) . toLocaleTimeString ( ) ) ) ;
events . addEventListener ( "stream" , ( event ) = > {
const stream = parseLogStreamEvent ( event ) ;
if ( ! stream ) return ;
ready = true ;
setStreams ( ( current ) = > ( { status : "ready" , data : mergeLogStreams ( current . status === "ready" ? current . data : [ ] , stream ) } ) ) ;
setSelectedStreamId ( ( current ) = > current || stream . id ) ;
} ) ;
events . addEventListener ( "ready" , ( ) = > {
ready = true ;
setStreams ( ( current ) = > current . status === "ready" ? current : { status : "ready" , data : [ ] } ) ;
} ) ;
events . addEventListener ( "log" , ( event ) = > {
const payload = parseServerLogEvent ( event ) ;
if ( ! payload ) return ;
ready = true ;
setLastRefreshAt ( new Date ( ) . toLocaleTimeString ( ) ) ;
setStreams ( ( current ) = > ( { status : "ready" , data : mergeLogStreams ( current . status === "ready" ? current . data : [ ] , streamFromServerLogEvent ( payload ) ) } ) ) ;
setSelectedStreamId ( ( current ) = > current || payload . streamId ) ;
if ( pausedRef . current ) return ;
setEntries ( ( current ) = > appendLiveLogEntries ( current , [ entryFromServerLogEvent ( payload ) ] , maxLogEntries ) ) ;
} ) ;
events . onerror = ( ) = > {
if ( ! ready ) setStreams ( { status : "error" , reason : "实时日志推送连接失败" } ) ;
} ;
return ( ) = > events . close ( ) ;
} , [ eventSourceKey , open , serverId ] ) ;
const selectedStream = streams . status === "ready" ? streams . data . find ( ( stream ) = > stream . id === selectedStreamId ) : undefined ;
const tailSelectedStream = useCallback ( async ( ) = > {
if ( ! open || ! selectedStream ) return ;
const afterSeq = cursorByStream [ selectedStream . id ] ? ? initialLogCursor ( selectedStream , liveLogHistoryWindow ) ;
const cursor = await platformApiClient . queryLogStream ( { logStreamId : selectedStream.id , afterSeq , limit : 100 } ) ;
const nextSeq = nextCursorSeq ( afterSeq , cursor . entries , cursor . nextSeq ) ;
setCursorByStream ( ( current ) = > updateCursor ( current , selectedStream . id , nextSeq ) ) ;
setLastRefreshAt ( new Date ( ) . toLocaleTimeString ( ) ) ;
if ( cursor . entries . length === 0 ) return ;
setEntries ( ( current ) = > [
. . . current ,
. . . cursor . entries . map ( ( entry ) = > ( { . . . entry , source : selectedStream.source || selectedStream . streamKey , streamId : selectedStream.id , streamKey : selectedStream.streamKey } ) )
] . slice ( - maxLogEntries ) ) ;
} , [ cursorByStream , open , selectedStream ] ) ;
useEffect ( ( ) = > {
if ( ! open || paused || ! selectedStream ) return undefined ;
void tailSelectedStream ( ) . catch ( ( ) = > undefined ) ;
const timer = window . setInterval ( ( ) = > void tailSelectedStream ( ) . catch ( ( ) = > undefined ) , liveLogPollMs ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ open , paused , selectedStream , tailSelectedStream ] ) ;
const visibleEntries = useMemo ( ( ) = > {
const query = keyword . trim ( ) . toLowerCase ( ) ;
return entries . filter ( ( entry ) = > entry . streamId === selectedStreamId && ( ! query || entry . line . toLowerCase ( ) . includes ( query ) || ( entry . level ? ? "info" ) . toLowerCase ( ) . includes ( query ) ) ) ;
@@ -146,35 +155,28 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
function clearVisibleBuffer() {
setEntries ( ( current ) = > current . filter ( ( entry ) = > entry . streamId !== selectedStreamId ) ) ;
if ( selectedStream ) setCursorByStream ( ( current ) = > ( { . . . current , [ selectedStream . id ] : selectedStream . latestSeq } ) ) ;
}
function selectLogStream ( nextStreamId : string ) {
setSelectedStreamId ( nextStreamId ) ;
setEntries ( [ ] ) ;
setCursorByStream ( ( current ) = > {
const next = { . . . current } ;
delete next [ nextStreamId ] ;
return next ;
} ) ;
}
return (
< LiveOperationDrawer open = { open } title = "实时日志" description = { ` ${ serverName } · 每 ${ liveLogPollMs / 1000 } 秒刷新一次 平台日志游标 ` } onClose = { onClose } >
< LiveOperationDrawer open = { open } title = "实时日志" description = { ` ${ serverName } · SSE 实时推送 平台日志 ` } onClose = { onClose } >
< div className = "log-filter-bar live-operation-toolbar" >
< select value = { selectedStreamId } aria-label = "选择日志源" onChange = { ( event ) = > selectLogStream ( event . target . value ) } >
{ streams . status === "ready" && streams . data . map ( ( stream ) = > < option key = { stream . id } value = { stream . id } > { stream . streamKey || stream . source } · seq { stream . latestSeq } < / option > ) }
< / select >
< input type = "search" value = { keyword } placeholder = "过滤可见日志" aria-label = "过滤可见日志" onChange = { ( event ) = > setKeyword ( event . target . value ) } / >
< button type = "button" className = "icon-command" onClick = { ( ) = > setPaused ( ( current ) = > ! current ) } > { paused ? < Play size = { 14 } / > : < Pause size = { 14 } / > } < span > { paused ? "继续" : "暂停" } < / span > < / button >
< button type = "button" className = "icon-command" onClick = { ( ) = > void tailSelectedStream ( ) . catch ( ( ) = > undefined ) } > < RotateCw size = { 14 } / > < span > 刷 新 < / span > < / button >
< button type = "button" className = "icon-command" onClick = { ( ) = > setEventSourceKey ( ( current ) = > current + 1 ) } > < RotateCw size = { 14 } / > < span > 重 连 < / span > < / button >
< button type = "button" className = "icon-command" onClick = { clearVisibleBuffer } > < Trash2 size = { 14 } / > < span > 清 屏 < / span > < / button >
< / div >
< span className = "page-status" > 状 态 : { paused ? "已暂停" : "自动刷新 " } · 最 新 刷 新 { lastRefreshAt || "等待" } · 游 标 { selectedStream ? cursorByStream [ selectedStream . id ] ? ? 0 : "--" } < / span >
< span className = "page-status" > 状 态 : { paused ? "已暂停" : "实时推送 " } · 最 新 事 件 { lastRefreshAt || "等待" } · 游 标 { selectedStream ? selectedStream . latestSeq : "--" } < / span >
{ streams . status === "loading" && < LoadingState label = "正在加载日志源…" compact / > }
{ streams . status === "error" && < ErrorState title = "实时日志不可用" reason = { streams . reason } diagnosticId = { ` live-logs: ${ serverId } ` } onRetry = { ( ) = > void loadStreams ( ) } compact / > }
{ streams . status === "ready" && streams . data . length === 0 && < EmptyState title = "暂无日志源" description = "运行端还没有向平台登记该服务器的日志流。" / > }
{ streams . status === "ready" && streams . data . length > 0 && visibleEntries . length === 0 && < EmptyState title = "等待日志" description = "没有新的匹配日志;保持窗口打开会继续按游标刷新 。" / > }
{ streams . status === "ready" && streams . data . length > 0 && visibleEntries . length === 0 && < EmptyState title = "等待日志" description = "没有新的匹配日志;保持窗口打开会继续接收平台推送 。" / > }
{ visibleEntries . length > 0 && (
< div className = "log-list live-log-list" role = "log" aria-live = { paused ? "off" : "polite" } >
{ visibleEntries . map ( ( entry ) = > (
@@ -204,7 +206,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const [ pending , setPending ] = useState ( false ) ;
const [ lines , setLines ] = useState < TerminalLine [ ] > ( [ ] ) ;
const [ streams , setStreams ] = useState < LoadState < LogStreamResponse [ ] > > ( { status : "loading" } ) ;
const [ cursorByStream , setCursorByStream ] = useState < Record < string , number > > ( { } ) ;
const [ commandHistory , setCommandHistory ] = useState < string [ ] > ( [ ] ) ;
const [ historyIndex , setHistoryIndex ] = useState < number | null > ( null ) ;
const [ result , setResult ] = useState < { status : "pending" | "succeeded" | "failed" ; label : string } | null > ( null ) ;
@@ -228,57 +229,43 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
} , [ open , serverId ] ) ;
const tailTerminalLogs = useCallback ( async ( targetStreams = terminalStreams ) = > {
if ( ! open || targetStreams . length === 0 ) return ;
const cursorUpdates : Record < string , number > = { } ;
const batches = await Promise . all ( targetStreams . map ( async ( stream ) = > {
const afterSeq = cursorByStream [ stream . id ] ? ? initialLogCursor ( stream , terminalHistoryWindow ) ;
const cursor = await platformApiClient . queryLogStream ( { logStreamId : stream.id , afterSeq , limit : terminalLogQueryLimit } ) ;
cursorUpdates [ stream . id ] = nextCursorSeq ( afterSeq , cursor . entries , cursor . nextSeq ) ;
return cursor . entries . map ( ( entry ) = > terminalLineFromLog ( stream , entry ) ) ;
} ) ) ;
setCursorByStream ( ( current ) = > {
let changed = false ;
const next = { . . . current } ;
for ( const [ streamId , cursor ] of Object . entries ( cursorUpdates ) ) {
const nextCursor = Math . max ( next [ streamId ] ? ? 0 , cursor ) ;
if ( nextCursor !== next [ streamId ] ) {
next [ streamId ] = nextCursor ;
changed = true ;
}
}
return changed ? next : current ;
} ) ;
appendLines ( batches . flat ( ) . sort ( compareTerminalLines ) ) ;
} , [ appendLines , cursorByStream , open , terminalStreams ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return ;
setCommand ( "" ) ;
setPending ( false ) ;
setResult ( null ) ;
setCursorByStream ( { } ) ;
setHistoryIndex ( null ) ;
setLines ( [ terminalSystemLine ( "info" , supportsCommands ? "读取平台历史日志,后续按游标 实时追加。" : "该插件暂未声明可用的管理终端命令通道。" , "SYSTEM" ) ] ) ;
setLines ( [ terminalSystemLine ( "info" , supportsCommands ? "连接平台日志推送,先补最近历史再 实时追加。" : "该插件暂未声明可用的管理终端命令通道。" , "SYSTEM" ) ] ) ;
void loadStreams ( ) ;
} , [ loadStreams , open , supportsCommands ] ) ;
useEffect ( ( ) = > {
if ( ! open ) return undefined ;
const timer = window . setInterval ( ( ) = > void loadStreams ( false ) . catch ( ( ) = > undefined ) , logStreamPollMs ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ loadStreams , open ] ) ;
useEffect ( ( ) = > {
if ( ! open || streams . status !== "ready" ) return ;
void tailTerminalLogs ( terminalRelevantStreams ( streams . data ) ) . catch ( ( error ) = > appendLines ( [ terminalSystemLine ( "error" , error instanceof Error ? error . message : "历史日志读取失败" , "LOGS" ) ] ) ) ;
} , [ appendLines , open , streams ] ) ;
useEffect ( ( ) = > {
if ( ! open || terminalStreams . length === 0 ) return undefined ;
const timer = window . setInterval ( ( ) = > void tailTerminalLogs ( ) . catch ( ( ) = > undefined ) , terminalLogPollMs ) ;
return ( ) = > window . clearInterval ( timer ) ;
} , [ open , tailTerminalLogs , terminalStreams ] ) ;
let ready = false ;
const events = platformApiClient . openServerLogEvents ( serverId , { historyLimit : terminalHistoryWindow } ) ;
events . addEventListener ( "stream" , ( event ) = > {
const stream = parseLogStreamEvent ( event ) ;
if ( ! stream ) return ;
ready = true ;
setStreams ( ( current ) = > ( { status : "ready" , data : mergeLogStreams ( current . status === "ready" ? current . data : [ ] , stream ) } ) ) ;
} ) ;
events . addEventListener ( "ready" , ( ) = > {
ready = true ;
setStreams ( ( current ) = > current . status === "ready" ? current : { status : "ready" , data : [ ] } ) ;
} ) ;
events . addEventListener ( "log" , ( event ) = > {
const payload = parseServerLogEvent ( event ) ;
if ( ! payload ) return ;
ready = true ;
const stream = streamFromServerLogEvent ( payload ) ;
setStreams ( ( current ) = > ( { status : "ready" , data : mergeLogStreams ( current . status === "ready" ? current . data : [ ] , stream ) } ) ) ;
appendLines ( [ terminalLineFromLog ( stream , payload . entry ) ] ) ;
} ) ;
events . onerror = ( ) = > {
if ( ! ready ) setStreams ( { status : "error" , reason : "实时日志推送连接失败" } ) ;
} ;
return ( ) = > events . close ( ) ;
} , [ appendLines , open , serverId ] ) ;
function selectQuickCommand ( item : TerminalQuickCommand ) {
setCommand ( item . command ) ;
@@ -322,11 +309,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setResult ( { status : "pending" , label : "正在提交命令" } ) ;
appendLines ( [ terminalSystemLine ( "input" , ` > ${ submitted } ` , "COMMAND" ) ] ) ;
try {
const response = await platformApiClient . sendSourceRCONCommand ( serverId , sourceRCONRawCommandRequest ( serverId , submitted ) ) ;
const label = ` 已排队 · 任务 ${ response . jobId } ` ;
setResult ( { status : "succeeded" , label } ) ;
appendLines ( [ terminalSystemLine ( "success" , ` ${ label } · ${ response . message || response . status } ` , "PLATFORM" , ` ok- ${ response . jobId } ` ) ] ) ;
void tailTerminalLogs ( ) . catch ( ( ) = > undefined ) ;
const response = await platformApiClient . queueGameClientBridgeCommand ( serverId , scumManagementRCONCommandRequest ( serverId , submitted ) ) ;
const label = bridgeCommandDispatchLabel ( response . state , response . id ) ;
setResult ( { status : "pending" , label : ` ${ label } · 等待 Run 返回结果 ` } ) ;
appendLines ( [ terminalSystemLine ( "success" , ` ${ label } · protected RCON ` , "PLATFORM" , ` ok- ${ response . id } ` ) ] ) ;
const finalCommand = await waitForBridgeCommandTerminal ( response . id ) ;
if ( finalCommand ) {
const outcome = terminalLineFromBridgeCommand ( finalCommand ) ;
setResult ( { status : outcome.tone === "success" ? "succeeded" : "failed" , label : outcome.text } ) ;
appendLines ( [ outcome ] ) ;
} else {
const timeoutLine = terminalSystemLine ( "warn" , ` 桥接命令 ${ response . id } 已排队,但尚未返回终态;继续观察实时日志。 ` , "PLATFORM" , ` pending- ${ response . id } ` ) ;
setResult ( { status : "pending" , label : "等待 Run 返回结果" } ) ;
appendLines ( [ timeoutLine ] ) ;
}
} catch ( error ) {
const label = error instanceof Error ? error . message : "命令提交失败" ;
setResult ( { status : "failed" , label } ) ;
@@ -336,13 +332,22 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
}
async function waitForBridgeCommandTerminal ( commandId : string ) : Promise < GameClientBridgeCommandResponse | null > {
for ( let attempt = 0 ; attempt < terminalBridgeResultPollAttempts ; attempt += 1 ) {
const current = await platformApiClient . getGameClientBridgeCommand ( serverId , commandId ) ;
if ( isTerminalBridgeCommandState ( current . state ) ) return current ;
await delay ( terminalBridgeResultPollMs ) ;
}
return null ;
}
return (
< LiveOperationDrawer open = { open } title = "管理终端" onClose = { onClose } backdropClassName = "terminal-drawer-backdrop" panelClassName = "management-terminal-drawer" bodyClassName = "management-terminal-body" hideHeader >
< section className = "terminal-output-panel" aria-label = "terminal output" >
< div className = "terminal-output-topbar" >
< div >
< strong > { serverName } < / strong >
< span > 最 近 历 史 + { terminalLogPollMs / 1000 } s 实 时 刷 新 · 日 志 源 { logStreamPollMs / 1000 } s 探 测 · { streams . status === "ready" ? ` ${ terminalStreams . length } 个日志源 ` : streams . status === "loading" ? "读取日志源" : "日志源异常" } < / span >
< span > 最 近 历 史 + SSE 实 时 推 送 · { streams . status === "ready" ? ` ${ terminalStreams . length } 个日志源 ` : streams . status === "loading" ? "读取日志源" : "日志源异常" } < / span >
< / div >
< div >
< button type = "button" className = "terminal-output-action" onClick = { clearTerminalBuffer } > < Trash2 size = { 14 } / > < span > 清 屏 < / span > < / button >
@@ -386,39 +391,35 @@ function levelClass(level?: string): string {
return "log-level-info" ;
}
function initialLogCursor ( stream : LogStreamResponse , historyWindow : number ) : number {
return Math . max ( 0 , stream . latestSeq - historyWindow ) ;
}
function nextCursorSeq ( afterSeq : number , entries : LogEntryBody [ ] , nextSeq : number ) : number {
if ( entries . length === 0 ) return Math . max ( afterSeq , nextSeq ) ;
return Math . max ( afterSeq , nextSeq , entries [ entries . length - 1 ] ? . seq ? ? afterSeq ) ;
}
function updateCursor ( current : Record < string , number > , streamId : string , cursor : number ) : Record < string , number > {
const nextCursor = Math . max ( current [ streamId ] ? ? 0 , cursor ) ;
if ( nextCursor === current [ streamId ] ) return current ;
return { . . . current , [ streamId ] : nextCursor } ;
}
function terminalQuickCommandsForPlugin ( pluginId : string ) : TerminalQuickCommand [ ] {
return terminalQuickCommandCatalog [ pluginId ] ? ? [ ] ;
}
function bridgeCommandDispatchLabel ( state : string , commandId : string ) : string {
return ` 已 ${ state === "pending" ? "排队" : "提交" } · 桥接命令 ${ commandId } ` ;
}
function terminalRelevantStreams ( streams : LogStreamResponse [ ] ) : LogStreamResponse [ ] {
return [ . . . streams ] . sort ( compareTerminalStreams ) . slice ( 0 , 8 ) ;
const active = streams . filter ( ( stream ) = > stream . latestSeq > 0 ) ;
const candidates = active . length > 0 ? active : streams ;
return [ . . . candidates ] . sort ( compareTerminalStreams ) . slice ( 0 , 12 ) ;
}
function compareTerminalStreams ( a : LogStreamResponse , b : LogStreamResponse ) : number {
return terminalStreamRank ( a ) - terminalStreamRank ( b ) || a . streamKey . localeCompare ( b . streamKey ) || a . id . localeCompare ( b . id ) ;
const rank = terminalStreamRank ( a ) - terminalStreamRank ( b ) ;
if ( rank !== 0 ) return rank ;
const updated = ( Date . parse ( b . updatedAt ) || 0 ) - ( Date . parse ( a . updatedAt ) || 0 ) ;
if ( updated !== 0 ) return updated ;
return b . latestSeq - a . latestSeq || a . streamKey . localeCompare ( b . streamKey ) || a . id . localeCompare ( b . id ) ;
}
function terminalStreamRank ( stream : LogStreamResponse ) : number {
const key = ` ${ stream . source } : ${ stream . streamKey } ` . toLowerCase ( ) ;
if ( key . includes ( "management-program ") ) return 0 ;
if ( key . includes ( "stderr " ) ) return 1 ;
if ( key . includes ( "stdout " ) ) return 2 ;
return 3 ;
if ( stream . source === "file" || key . includes ( "scum. ") ) return 0 ;
if ( key . includes ( "management-program " ) ) return 1 ;
if ( key . includes ( "stderr " ) ) return 2 ;
if ( key . includes ( "stdout" ) ) return 3 ;
return 4 ;
}
function terminalLineFromLog ( stream : LogStreamResponse , entry : LogEntryBody ) : TerminalLine {
@@ -439,6 +440,34 @@ function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey:
return { id , tone , text , at : new Date ( now ) . toLocaleTimeString ( ) , sortKey : now , streamKey } ;
}
function isTerminalBridgeCommandState ( state : GameClientBridgeCommandResponse [ "state" ] ) : boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown" ;
}
function terminalLineFromBridgeCommand ( command : GameClientBridgeCommandResponse ) : TerminalLine {
const summary = command . result ? . summary || command . resultSummary || command . cancellation ? . reason || bridgeCommandStateLabel ( command . state ) ;
const completed = command . completedAt || command . result ? . completedAt || command . cancellation ? . cancelledAt || command . updatedAt ;
const sortKey = Date . parse ( completed ) || Date . now ( ) ;
const tone : TerminalLine [ "tone" ] = command . state === "succeeded" ? "success" : command . state === "failed" ? "error" : "warn" ;
return { id : ` bridge- ${ command . id } - ${ command . state } ` , tone , text : ` 桥接命令 ${ command . id } · ${ bridgeCommandStateLabel ( command . state ) } · ${ summary } ` , at : new Date ( sortKey ) . toLocaleTimeString ( ) , sortKey , streamKey : "BRIDGE" } ;
}
function bridgeCommandStateLabel ( state : GameClientBridgeCommandResponse [ "state" ] ) : string {
switch ( state ) {
case "succeeded" : return "已成功" ;
case "failed" : return "已失败" ;
case "cancelled" : return "已取消" ;
case "expired" : return "已过期" ;
case "unknown" : return "状态未知" ;
case "claimed" : return "Run 已领取" ;
case "pending" : return "已排队" ;
}
}
function delay ( ms : number ) : Promise < void > {
return new Promise ( ( resolve ) = > window . setTimeout ( resolve , ms ) ) ;
}
function terminalTone ( entry : LogEntryBody ) : TerminalLine [ "tone" ] {
const value = ` ${ entry . level ? ? "" } ${ entry . line } ` . toLowerCase ( ) ;
if ( /\b(error|fatal|panic|exception|failed|failure)\b/ . test ( value ) ) return "error" ;