@@ -1,5 +1,5 @@
import { History , ListChecks , Send , Sparkles , Terminal , Trash2 , X } from "lucide-react" ;
import { History , Trash2 , X } from "lucide-react" ;
import { type FormEvent , type KeyboardEvent as ReactKeyboardEvent , type ReactNode , useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import { type FormEvent , type ReactNode , useCallback , useEffect , useRef , useState } from "react" ;
import { platformApiClient } from "../api/client" ;
import { platformApiClient } from "../api/client" ;
import type { JobResponse , LogEntryBody , LogStreamResponse } from "../api/types" ;
import type { JobResponse , LogEntryBody , LogStreamResponse } from "../api/types" ;
@@ -7,25 +7,23 @@ import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes" ;
import { cx } from "../utils/classes" ;
import { mergeLogStreams , parseLogSessionEvent , parseLogStreamEvent , parseServerLogEvent , streamFromServerLogEvent } from "../utils/logEvents" ;
import { mergeLogStreams , parseLogSessionEvent , parseLogStreamEvent , parseServerLogEvent , streamFromServerLogEvent } from "../utils/logEvents" ;
import { formatTerminalLogTime , formatTerminalServerTime } from "../utils/logTime" ;
import { formatTerminalLogTime , formatTerminalServerTime } from "../utils/logTime" ;
import { EmptyState , ResultBadge } from "./StateViews" ;
import { ResultBadge } from "./StateViews" ;
type LoadState < T > = { status : "loading" } | { status : "error" ; reason : string } | { status : "ready" ; data : T } ;
type LoadState < T > = { status : "loading" } | { status : "error" ; reason : string } | { status : "ready" ; data : T } ;
type HistoryLineState = { status : "idle" } | LoadState < TerminalLine [ ] > ;
type HistoryLineState = { status : "idle" } | LoadState < TerminalLine [ ] > ;
type TerminalLine = { id : string ; tone : "input" | "info" | "success" | "warn" | "error" ; text : string ; at : string ; sortKey : number ; streamKey? : string ; level? : string ; seq? : number } ;
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 terminalJobResultPollMs = 1000 ;
const terminalJobResultPollAttempts = 30 ;
const terminalHistoryWindow = 500 ;
const terminalHistoryWindow = 500 ;
const maxTerminalLines = 10000 ;
const maxTerminalLines = 10000 ;
const terminalQuickCommandCatalog : Record < string , TerminalQuickCommand [ ] > = {
const terminalJobResultPollMs = 1000 ;
"game.scum" : [
const terminalJobResultPollAttempts = 30 ;
{ label : "查询玩家" , command : "#ListPlayers" , hint : "在线玩家列表" } ,
// Game command catalogs are plugin-owned; this extension point intentionally
{ label : "查询队伍" , command : "#ListSquads 1" , hint : "服务器队伍列表" } ,
// contains no platform-provided game commands.
{ label : "查询车辆" , command : "#ListSpawnedVehicles true" , hint : "已生成车辆列表" } ,
const terminalQuickCommandCatalog : Record < string , never [ ] > = { } ;
{ label : "设为中午" , command : "#SetTime 12" , hint : "设置游戏时间" }
]
function terminalQuickCommandsForPlugin ( pluginId : string ) : never [ ] {
} ;
return terminalQuickCommandCatalog [ pluginId ] ? ? [ ] ;
}
interface LiveOperationDrawerProps {
interface LiveOperationDrawerProps {
open : boolean ;
open : boolean ;
@@ -77,19 +75,15 @@ interface ServerManagementTerminalDrawerProps {
open : boolean ;
open : boolean ;
serverId : string ;
serverId : string ;
serverName : string ;
serverName : string ;
pluginId : string ;
canManage : boolean ;
onClose : ( ) = > void ;
onClose : ( ) = > void ;
}
}
export function ServerManagementTerminalDrawer ( { open , serverId , serverName , pluginId , canManage , onClose } : ServerManagementTerminalDrawerProps ) {
export function ServerManagementTerminalDrawer ( { open , serverId , serverName , onClose } : ServerManagementTerminalDrawerProps ) {
const [ command , setCommand ] = useState ( "" ) ;
const [ command , setCommand ] = useState ( "" ) ;
const [ pending , setPending ] = useState ( false ) ;
const [ pending , setPending ] = useState ( false ) ;
const [ result , setResult ] = useState < { status : "pending" | "succeeded" | "failed" ; label : string } | null > ( null ) ;
const [ lines , setLines ] = useState < TerminalLine [ ] > ( [ ] ) ;
const [ lines , setLines ] = useState < TerminalLine [ ] > ( [ ] ) ;
const [ streams , setStreams ] = useState < LoadState < LogStreamResponse [ ] > > ( { status : "loading" } ) ;
const [ streams , setStreams ] = useState < LoadState < LogStreamResponse [ ] > > ( { status : "loading" } ) ;
const [ commandHistory , setCommandHistory ] = useState < string [ ] > ( [ ] ) ;
const [ historyIndex , setHistoryIndex ] = useState < number | null > ( null ) ;
const [ result , setResult ] = useState < { status : "pending" | "succeeded" | "failed" ; label : string } | null > ( null ) ;
const [ followLatest , setFollowLatest ] = useState ( true ) ;
const [ followLatest , setFollowLatest ] = useState ( true ) ;
const [ liveSessionId , setLiveSessionId ] = useState < string | null > ( null ) ;
const [ liveSessionId , setLiveSessionId ] = useState < string | null > ( null ) ;
const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
const [ historyOpen , setHistoryOpen ] = useState ( false ) ;
@@ -102,8 +96,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const initialHistoryPendingRef = useRef ( false ) ;
const initialHistoryPendingRef = useRef ( false ) ;
const liveSessionRef = useRef < string | null | undefined > ( undefined ) ;
const liveSessionRef = useRef < string | null | undefined > ( undefined ) ;
const historyRequestRef = useRef ( 0 ) ;
const historyRequestRef = useRef ( 0 ) ;
const quickCommands = useMemo ( ( ) = > terminalQuickCommandsForPlugin ( pluginId ) , [ pluginId ] ) ;
const supportsCommands = quickCommands . length > 0 ;
const appendLines = useCallback ( ( incoming : TerminalLine [ ] ) = > {
const appendLines = useCallback ( ( incoming : TerminalLine [ ] ) = > {
if ( incoming . length === 0 ) return ;
if ( incoming . length === 0 ) return ;
@@ -130,7 +122,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setCommand ( "" ) ;
setCommand ( "" ) ;
setPending ( false ) ;
setPending ( false ) ;
setResult ( null ) ;
setResult ( null ) ;
setHistoryIndex ( null ) ;
setLiveSessionId ( null ) ;
setLiveSessionId ( null ) ;
setHistoryOpen ( false ) ;
setHistoryOpen ( false ) ;
setHistoryStreams ( { status : "loading" } ) ;
setHistoryStreams ( { status : "loading" } ) ;
@@ -142,8 +133,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
initialHistoryPendingRef . current = true ;
initialHistoryPendingRef . current = true ;
followLatestRef . current = true ;
followLatestRef . current = true ;
setFollowLatest ( true ) ;
setFollowLatest ( true ) ;
setLines ( [ terminalSystemLine ( "info" , supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。" , "SYSTEM" , undefined , serverTimeRef . current ) ] ) ;
setLines ( [ terminalSystemLine ( "info" , "正在连接当前受管进程输出。" , "SYSTEM" , undefined , serverTimeRef . current ) ] ) ;
} , [ open , supportsCommands ] ) ;
} , [ open ] ) ;
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( ! open || initialHistoryPendingRef . current || ! followLatestRef . current ) return undefined ;
if ( ! open || initialHistoryPendingRef . current || ! followLatestRef . current ) return undefined ;
@@ -228,33 +219,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
}
}
}
function selectQuickCommand ( item : TerminalQuickCommand ) {
setCommand ( item . command ) ;
setHistoryIndex ( null ) ;
}
function handleCommandKeyDown ( event : ReactKeyboardEvent < HTMLInputElement > ) {
if ( event . key !== "ArrowUp" && event . key !== "ArrowDown" ) return ;
if ( commandHistory . length === 0 ) return ;
event . preventDefault ( ) ;
const lastIndex = commandHistory . length - 1 ;
if ( event . key === "ArrowUp" ) {
const nextIndex = historyIndex === null ? lastIndex : Math.max ( 0 , historyIndex - 1 ) ;
setHistoryIndex ( nextIndex ) ;
setCommand ( commandHistory [ nextIndex ] ? ? "" ) ;
return ;
}
if ( historyIndex === null ) return ;
const nextIndex = historyIndex + 1 ;
if ( nextIndex > lastIndex ) {
setHistoryIndex ( null ) ;
setCommand ( "" ) ;
return ;
}
setHistoryIndex ( nextIndex ) ;
setCommand ( commandHistory [ nextIndex ] ? ? "" ) ;
}
function clearTerminalBuffer() {
function clearTerminalBuffer() {
if ( historyOpen ) {
if ( historyOpen ) {
setHistoryLines ( { status : "ready" , data : [ ] } ) ;
setHistoryLines ( { status : "ready" , data : [ ] } ) ;
@@ -280,33 +244,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
async function submitCommand ( event : FormEvent < HTMLFormElement > ) {
async function submitCommand ( event : FormEvent < HTMLFormElement > ) {
event . preventDefault ( ) ;
event . preventDefault ( ) ;
if ( ! supportsCommands || ! canManage || pending || ! command . trim ( ) ) return ;
if ( pending || ! command . trim ( ) ) return ;
const submitted = command . trim ( ) ;
const submitted = command . trim ( ) ;
setCommand ( "" ) ;
setCommand ( "" ) ;
setHistoryIndex ( null ) ;
setCommandHistory ( ( current ) = > [ . . . current . filter ( ( item ) = > item !== submitted ) , submitted ] . slice ( - 50 ) ) ;
setPending ( true ) ;
setPending ( true ) ;
setResult ( { status : "pending" , label : "正在提交命令" } ) ;
setResult ( { status : "pending" , label : "正在提交命令" } ) ;
appendLines ( [ terminalSystemLine ( "input" , ` > ${ submitted } ` , "COMMAND" , undefined , serverTimeRef . current ) ] ) ;
try {
try {
const response = await platformApiClient . dispatchSourceRCONCommand ( serverId , scumSourceRCONCommandRequest ( serverId , submitted ) ) ;
const response = await platformApiClient . dispatchSourceRCONCommand ( serverId , scumSourceRCONCommandRequest ( serverId , submitted ) ) ;
const label = rconJobDispatchLabel ( response . status , response . jobId ) ;
const label = ` 已 ${ response . status === "queued" ? "排队" : "提交" } · RCON 任务 ${ response . jobId } ` ;
setResult ( { status : "pending" , label : ` ${ label } · 等待 Run 返回结果 ` } ) ;
setResult ( { status : "pending" , label } ) ;
appendLines ( [ terminalSystemLine ( "success" , ` ${ label } · Source RCON ` , "PLATFORM" , ` ok- ${ response . jobId } ` , serverTimeRef . current ) ] ) ;
const finalJob = await waitForRCONJobTerminal ( response . jobId ) ;
const finalJob = await waitForRCONJobTerminal ( response . jobId ) ;
if ( finalJob ) {
if ( finalJob ) setResult ( { status : finalJob.state === "succeeded" ? "succeeded" : "failed" , label : finalJob.progress.message || finalJob . executionResult ? . summary || finalJob . state } ) ;
const outcome = terminalLineFromJob ( finalJob ) ;
setResult ( { status : outcome.tone === "success" ? "succeeded" : "failed" , label : outcome.text } ) ;
appendLines ( [ outcome ] ) ;
} else {
const timeoutLine = terminalSystemLine ( "warn" , ` RCON 任务 ${ response . jobId } 已排队,但尚未返回终态;继续观察实时日志。 ` , "PLATFORM" , ` pending- ${ response . jobId } ` , serverTimeRef . current ) ;
setResult ( { status : "pending" , label : "等待 Run 返回结果" } ) ;
appendLines ( [ timeoutLine ] ) ;
}
} catch ( error ) {
} catch ( error ) {
const label = error instanceof Error ? error . message : "命令提交失败" ;
setResult ( { status : "failed" , label : error instanceof Error ? error . message : "命令提交失败" } ) ;
setResult ( { status : "failed" , label } ) ;
appendLines ( [ terminalSystemLine ( "error" , label , "ERROR" , undefined , serverTimeRef . current ) ] ) ;
} finally {
} finally {
setPending ( false ) ;
setPending ( false ) ;
}
}
@@ -315,8 +265,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
async function waitForRCONJobTerminal ( jobId : string ) : Promise < JobResponse | null > {
async function waitForRCONJobTerminal ( jobId : string ) : Promise < JobResponse | null > {
for ( let attempt = 0 ; attempt < terminalJobResultPollAttempts ; attempt += 1 ) {
for ( let attempt = 0 ; attempt < terminalJobResultPollAttempts ; attempt += 1 ) {
const current = await platformApiClient . getJob ( jobId ) ;
const current = await platformApiClient . getJob ( jobId ) ;
if ( isTerminalJobState ( current . state ) ) return current ;
if ( current . state === "succeeded" || current . state === "failed" || current . state === "cancelled" ) return current ;
await delay ( terminalJobResultPollMs ) ;
await new Promise ( ( resolve ) = > window . setTimeout ( resolve , terminalJobResultPollMs ) ) ;
}
}
return null ;
return null ;
}
}
@@ -343,24 +293,11 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
< / div >
< / div >
< / section >
< / section >
< section className = "terminal-command-dock" aria-label = "terminal command controls" >
< section className = "terminal-command-dock" aria-label = "terminal command controls" >
< div className = "terminal-command-dock-header" >
< div className = "terminal-command-dock-header" > < span > 插 件 命 令 通 道 < / span > { result && < ResultBadge status = { result . status } label = { result . label } / > } < / div >
< span > < ListChecks size = { 14 } / > { quickCommands . length > 0 ? "插件快捷指令" : "命令输入" } < / span >
{ result && < ResultBadge status = { result . status } label = { result . label } / > }
< / div >
{ quickCommands . length > 0 && < div className = "terminal-quick-command-list" >
{ quickCommands . map ( ( item ) = > < button key = { item . command } type = "button" className = "terminal-quick-command" disabled = { ! canManage || pending } onClick = { ( ) = > selectQuickCommand ( item ) } > < strong > { item . label } < / strong > < span > { item . command } < / span > < small > { item . hint } < / small > < / button > ) }
< / div > }
{ ! supportsCommands && < EmptyState icon = { < Terminal size = { 24 } / > } title = "终端不可用" description = "当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" / > }
{ supportsCommands && (
< form className = "terminal-command-form" onSubmit = { ( event ) = > void submitCommand ( event ) } >
< form className = "terminal-command-form" onSubmit = { ( event ) = > void submitCommand ( event ) } >
< label >
< label > < span > 插 件 声 明 的 命 令 < / span > < small > 命 令 正 文 由 插 件 解 释 , 平 台 仅 负 责 授 权 、 排 队 和 转 发 。 < / small > < input value = { command } disabled = { pending } placeholder = "输入插件命令" onChange = { ( event ) = > setCommand ( event . target . value ) } / > < / label >
< span > SCUM 管 理 命 令 < / span >
< button type = "submit" className = "primary-command" disabled = { pending || ! command . trim ( ) } > { pending ? "提交中…" : "发送" } < / button >
< small > Enter 发 送 , ↑ / ↓ 调 出 历 史 ; 实 时 日 志 来 自 受 管 进 程 输 出 。 < / small >
< input value = { command } disabled = { ! canManage || pending } placeholder = { canManage ? "输入单行命令" : "当前账号没有运行操作权限" } onKeyDown = { handleCommandKeyDown } onChange = { ( event ) = > { setCommand ( event . target . value ) ; setHistoryIndex ( null ) ; } } / >
< / label >
< button type = "submit" className = "primary-command" disabled = { ! canManage || pending || ! command . trim ( ) } > { pending ? < Sparkles size = { 15 } / > : < Send size = { 15 } / > } < span > { pending ? "提交中…" : "发送" } < / span > < / button >
< / form >
< / form >
) }
< / section >
< / section >
< / LiveOperationDrawer >
< / LiveOperationDrawer >
) ;
) ;
@@ -402,20 +339,12 @@ function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn"
return < div className = { ` terminal-line terminal-line- ${ tone } terminal-source-system ` } > < time > { formatTerminalServerTime ( serverTime ) } < / time > < span className = "terminal-text" > { label } < / span > < / div > ;
return < div className = { ` terminal-line terminal-line- ${ tone } terminal-source-system ` } > < time > { formatTerminalServerTime ( serverTime ) } < / time > < span className = "terminal-text" > { label } < / span > < / div > ;
}
}
function terminalQuickCommandsForPlugin ( pluginId : string ) : TerminalQuickCommand [ ] {
return terminalQuickCommandCatalog [ pluginId ] ? ? [ ] ;
}
function rconJobDispatchLabel ( state : string , jobId : string ) : string {
return ` 已 ${ state === "queued" ? "排队" : "提交" } · RCON 任务 ${ jobId } ` ;
}
function terminalLineFromLog ( stream : LogStreamResponse , entry : LogEntryBody ) : TerminalLine {
function terminalLineFromLog ( stream : LogStreamResponse , entry : LogEntryBody ) : TerminalLine {
return {
return {
id : ` log- ${ stream . id } - ${ entry . seq } ` ,
id : ` log- ${ stream . id } - ${ entry . seq } ` ,
tone : terminalTone ( entry ) ,
tone : terminalTone ( entry ) ,
text : entry.line ,
text : entry.line ,
at : formatTerminalLogTime ( entry . timestamp , entry . line ) ,
at : formatTerminalLogTime ( entry . timestamp ) ,
sortKey : Date.parse ( entry . timestamp ) || Date . now ( ) ,
sortKey : Date.parse ( entry . timestamp ) || Date . now ( ) ,
streamKey : stream.streamKey || stream . source ,
streamKey : stream.streamKey || stream . source ,
level : entry.level ,
level : entry.level ,
@@ -452,39 +381,10 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
return Boolean ( normalizedEventSessionId && normalizedEventSessionId === liveSessionId ) ;
return Boolean ( normalizedEventSessionId && normalizedEventSessionId === liveSessionId ) ;
}
}
function isTerminalJobState ( state : JobResponse [ "state" ] ) : boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" ;
}
function terminalLineFromJob ( job : JobResponse ) : TerminalLine {
const summary = job . progress . message || job . executionResult ? . summary || job . cancelReason || jobStateLabel ( job . state ) ;
const completed = job . updatedAt ;
const sortKey = Date . parse ( completed ) || Date . now ( ) ;
const tone : TerminalLine [ "tone" ] = job . state === "succeeded" ? "success" : job . state === "failed" ? "error" : "warn" ;
return { id : ` rcon-job- ${ job . id } - ${ job . state } ` , tone , text : ` RCON 任务 ${ job . id } · ${ jobStateLabel ( job . state ) } · ${ summary } ` , at : formatTerminalServerTime ( job . updatedAt ) , sortKey , streamKey : "PLATFORM" } ;
}
function jobStateLabel ( state : JobResponse [ "state" ] ) : string {
switch ( state ) {
case "succeeded" : return "已成功" ;
case "failed" : return "已失败" ;
case "cancelled" : return "已取消" ;
case "accepted" : return "Run 已领取" ;
case "running" : return "运行中" ;
case "retrying" : return "等待重试" ;
case "queued" : return "已排队" ;
}
}
function delay ( ms : number ) : Promise < void > {
return new Promise ( ( resolve ) = > window . setTimeout ( resolve , ms ) ) ;
}
function terminalTone ( entry : LogEntryBody ) : TerminalLine [ "tone" ] {
function terminalTone ( entry : LogEntryBody ) : TerminalLine [ "tone" ] {
const value = ` ${ entry . level ? ? "" } ${ entry . line } ` . toLowerCase ( ) ;
// Log bodies are plugin-owned opaque content. The platform/browser must not
if ( /\b(error|fatal|panic|exception|failed|failure)\b/ . test ( value ) ) return "error" ;
// inspect text to infer severity or derive a typed result from it.
if ( /\b(warn|warning|timeout|retry)\b/ . test ( value ) ) return "warn" ;
void entry ;
if ( /\b(success|succeeded|ready|started|online|listening|accepted)\b/ . test ( value ) ) return "success" ;
return "info" ;
return "info" ;
}
}