Fix run log ingest and terminal console layout

This commit is contained in:
npc0-hue
2026-08-03 12:49:46 +08:00
parent 1d5fb586d0
commit a1bf3aa86a
15 changed files with 390 additions and 34 deletions
@@ -9,6 +9,7 @@ Server cards and server detail headers already consume `GET /api/v1/metrics/serv
- Refresh server list and detail operational data automatically at bounded intervals.
- Keep `编辑部署` on the server list and remove deployment editing from the server detail page.
- Replace the low-value detail overview surface with direct operational sections and drawers for live logs and management terminal.
- Ensure Run job stdout/stderr batches have pre-registered platform log streams and can be accepted using the current Run spool checksum/sequence behavior.
- Keep logs and commands readable in the existing game-operations theme and shared console primitives.
**Non-Goals:**
@@ -32,11 +33,17 @@ Alternative considered: introduce WebSockets for true streaming. That is better
### Decision 3: Drawers for live operational windows
Server list actions open drawers/dialogs for `实时日志` and `管理终端`. These windows do not resize cards, cover metrics inside the card, or create a tall action stack. The log drawer supports source selection, pause/resume, manual refresh, clear visible buffer, and autoscroll. The terminal drawer provides command history-like output and uses the existing SCUM Source RCON dispatch for SCUM servers; unsupported plugins show a safe unsupported state.
Server list actions open drawers/dialogs for `实时日志` and `管理终端`. These windows do not resize cards, cover metrics inside the card, or create a tall action stack. The log drawer supports source selection, pause/resume, manual refresh, clear visible buffer, and autoscroll. The terminal drawer provides an opaque 100%-width, near-full-height command console with command history, bottom input, and quick command templates. It uses the existing SCUM Source RCON dispatch for SCUM servers; unsupported plugins show a safe unsupported state.
Alternative considered: route every action through the detail page. Operators asked for card-level fast access, so drawers preserve context without forcing navigation.
### Decision 4: Detail page starts as an operations workspace
### Decision 4: Platform owns job log stream metadata
Run currently spools process logs using deterministic job stream IDs such as `job.<jobID>.stdout` and uploads single-line batches with a line checksum. Platform job creation will pre-register the matching job stdout/stderr streams, management-program command jobs will also get mediated program stdout/stderr streams, and durable service startup will recover those stream records for legacy jobs persisted before this repair. Log ingest will accept the current Run single-line checksum and first sequence for a new stream while keeping strict continuation once a stream has acknowledged entries.
Alternative considered: require Run to create streams through a platform-admin route before upload. That would either grant Run broader metadata creation authority or continue to leave normal process logs invisible, so it is rejected for this repair.
### Decision 5: Detail page starts as an operations workspace
The detail page no longer exposes deployment editing and does not keep a default overview panel. Its navigation starts on live logs and keeps configuration, plugin controls, AI assistant, operation history, and runtime controls as explicit sections. Runtime binding and distribution controls remain available where they are operational controls, not the create/list deployment editor.
@@ -9,6 +9,7 @@ Server management currently presents live-looking server values that are not liv
- Update server cards to show CPU, memory, and disk with visible progress meters and replace the detail/deployment action cluster with direct `实时日志` and `管理终端` entry points while keeping `编辑部署` available only on the server list.
- Remove the low-value server detail overview surface; the detail page becomes an operations workspace focused on logs, management terminal, runtime controls, configuration, plugin controls, AI assistance, and history.
- Introduce a safe live log drawer that tails platform log streams through cursor polling rather than exposing host paths, sockets, or Run credentials.
- Register job log streams and accept the current Run spool checksum/sequence shape so stdout/stderr batches can appear in the live log drawer.
- Introduce a safe management terminal drawer that sends plugin/platform-mediated commands, starting with the existing SCUM Source RCON command path, and shows task submission/status without exposing raw shell access.
## Capabilities
@@ -21,6 +22,6 @@ Server management currently presents live-looking server values that are not liv
## Impact
- Affected roots: `platform/` for per-server metric projection, `platform_web/` for server list/detail interaction, polling, logs, terminal UI, and focused tests.
- Affected roots: `platform/` for per-server metric projection and job log stream compatibility, `platform_web/` for server list/detail interaction, polling, logs, terminal UI, and focused tests.
- Existing Run metric ingest, log stream cursor query, job tracking, and SCUM Source RCON command APIs are reused; no new run source tree or browser-direct shell is introduced.
- Verification: focused Go tests for metrics projection, frontend tests/typecheck/build as needed, `scripts/check-structure.sh`, and `openspec validate repair-server-live-operations-console --strict`.
@@ -48,6 +48,16 @@ The live log window SHALL read platform log streams and entries through authoriz
- **WHEN** an operator pauses, resumes, clears, filters, or manually refreshes live logs
- **THEN** the UI updates only the visible log window state and does not expose host paths, raw sockets, credentials, or Run endpoint addresses
#### Scenario: Run job logs are uploaded
- **WHEN** Platform creates a server-scoped job that Run may execute
- **THEN** Platform pre-registers the job stdout and stderr log streams that match Run's deterministic spool stream IDs
- **AND** Run log batches using the current single-line checksum and an initial global sequence number are accepted and visible through cursor queries
#### Scenario: Legacy persisted jobs have no log streams
- **WHEN** Platform starts with existing server-scoped jobs that were persisted before job log streams were auto-created
- **THEN** Platform recovers the missing job stdout and stderr stream metadata before log cursor recovery
- **AND** mediated management-program jobs recover their program stdout and stderr stream metadata as well
### Requirement: Management terminal is platform-mediated
The management terminal SHALL dispatch commands only through platform-authorized plugin or lifecycle command paths and SHALL NOT provide browser-direct shell access.
@@ -55,6 +65,10 @@ The management terminal SHALL dispatch commands only through platform-authorized
- **WHEN** an operator submits a SCUM management command from the terminal
- **THEN** the UI dispatches the command through the existing Source RCON command API and displays safe submission/job status
#### Scenario: Terminal window opens
- **WHEN** an operator opens `管理终端`
- **THEN** the UI presents an opaque near-full-height command console with bottom command input and quick command templates rather than a translucent narrow drawer
#### Scenario: Unsupported command target
- **WHEN** a server plugin has no supported management terminal command path
- **THEN** the terminal shows an unsupported state instead of exposing a raw shell or arbitrary command input
@@ -13,6 +13,9 @@
- [x] 3.1 Add a live log drawer that lists server log streams and tails selected entries with cursor polling plus pause, clear, filter, and manual refresh controls.
- [x] 3.2 Add a management terminal drawer that dispatches SCUM commands through the existing Source RCON API and shows safe unsupported state for other plugins.
- [x] 3.3 Wire server list card actions to `编辑部署`, `实时日志`, and `管理终端` without reflowing cards or moving deployment editing into detail.
- [x] 3.4 Register job stdout/stderr log streams and accept current Run spool checksum/sequence behavior so live logs can populate.
- [x] 3.5 Convert the management terminal to an opaque full-width command console with bottom input and quick command templates.
- [x] 3.6 Recover missing job log streams for legacy persisted jobs at durable service startup.
## 4. Server Detail Workspace
+4 -3
View File
@@ -171,9 +171,10 @@ const (
type LogStreamSource string
const (
LogStreamSourceProcess LogStreamSource = "process"
LogStreamSourceFile LogStreamSource = "file"
LogStreamSourcePlugin LogStreamSource = "plugin"
LogStreamSourceProcess LogStreamSource = "process"
LogStreamSourceFile LogStreamSource = "file"
LogStreamSourcePlugin LogStreamSource = "plugin"
LogStreamSourceManagementProgram LogStreamSource = "management-program"
)
type LogStorageBackend string
+12 -2
View File
@@ -31,7 +31,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
if err != nil {
return domain.LogBatchIngestResult{}, err
}
if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum {
if exists && record.LastSeq == batch.LastSeq && logBatchRecordMatches(record, batch) {
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
@@ -50,7 +50,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}
return domain.LogBatchIngestResult{}, validationError("log batch conflicts with acknowledged range")
}
if batch.FirstSeq != stream.LatestSeq+1 {
if stream.LatestSeq > 0 && batch.FirstSeq != stream.LatestSeq+1 {
return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
}
@@ -86,6 +86,16 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}, nil
}
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
if record.Checksum == batch.Checksum {
return true
}
if len(record.Entries) == 1 && len(batch.Entries) == 1 {
return batch.Checksum == validator.LogLineChecksum(record.Entries[0].Line)
}
return false
}
// sanitizeGamePlayerNetworkFields removes raw network material before the durable log body is written.
func sanitizeGamePlayerNetworkFields(batch *domain.LogBatchIngest) {
for index := range batch.Entries {
+68 -2
View File
@@ -56,16 +56,80 @@ func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
}
}
func TestCoreServiceAcceptsAutoCreatedRunJobLogStreams(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
job, err := svc.CreateJob(domain.Job{
ID: "job-run-logs",
ServerInstanceID: "server-1",
RunEndpointID: "run-local",
Capability: domain.LifecycleCapabilityStart,
IdempotencyKey: "job-run-logs",
})
if err != nil {
t.Fatalf("create job: %v", err)
}
streamID := jobLogStreamID(job.ID, "stderr")
stream, err := svc.GetLogStream(streamID)
if err != nil {
t.Fatalf("get auto-created job log stream: %v", err)
}
if stream.StreamKey != "stderr" || stream.Source != domain.LogStreamSourceProcess {
t.Fatalf("unexpected stream metadata: %+v", stream)
}
entry := domain.LogEntry{Seq: 2, Timestamp: time.Date(2026, 7, 3, 12, 0, 2, 0, time.UTC), Level: "info", Line: "stderr:server-ready"}
batch := domain.LogBatchIngest{
RunEndpointID: "run-local",
SessionToken: sessionToken,
LogStreamID: streamID,
ServerInstanceID: "server-1",
StreamKey: "stderr",
Source: domain.LogStreamSourceProcess,
FirstSeq: entry.Seq,
LastSeq: entry.Seq,
Compression: "none",
Checksum: validator.LogLineChecksum(entry.Line),
Entries: []domain.LogEntry{entry},
}
ack, err := svc.IngestLogBatch(batch)
if err != nil {
t.Fatalf("ingest run job log batch: %v", err)
}
if !ack.Accepted || ack.LatestSeq != entry.Seq {
t.Fatalf("unexpected ack: %+v", ack)
}
duplicate, err := svc.IngestLogBatch(batch)
if err != nil {
t.Fatalf("ingest duplicate run job log batch: %v", err)
}
if !duplicate.Duplicate {
t.Fatalf("expected duplicate ack, got %+v", duplicate)
}
query, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: streamID, AfterSeq: 0, Limit: 10})
if err != nil {
t.Fatalf("query auto-created job log stream: %v", err)
}
if len(query.Entries) != 1 || query.Entries[0].Line != entry.Line || query.NextSeq != entry.Seq {
t.Fatalf("unexpected query result: %+v", query)
}
}
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
svc, sessionToken := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
gap := validLogBatch(t, sessionToken, 2, 2)
first := validLogBatch(t, sessionToken, 1, 1)
if _, err := svc.IngestLogBatch(first); err != nil {
t.Fatalf("ingest first batch: %v", err)
}
gap := validLogBatch(t, sessionToken, 3, 3)
_, err := svc.IngestLogBatch(gap)
if err == nil || !strings.Contains(err.Error(), "firstSeq") {
t.Fatalf("expected out-of-order rejection, got %v", err)
}
svc, sessionToken = newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
batch := validLogBatch(t, sessionToken, 1, 2)
if _, err := svc.IngestLogBatch(batch); err != nil {
t.Fatalf("ingest first batch: %v", err)
@@ -139,7 +203,9 @@ func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
}); err != nil {
t.Fatalf("create server instance: %v", err)
}
hello, err := svc.RegisterRunHello(validRunControlHello())
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityStart)
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register run hello: %v", err)
}
+81
View File
@@ -328,6 +328,9 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
for _, session := range sessions {
service.artifactTransfers[session.TransferID] = domain.CopyArtifactTransferSession(session)
}
if err := service.recoverJobLogStreams(); err != nil {
return nil, err
}
if err := service.recoverLogCursors(); err != nil {
return nil, err
}
@@ -343,6 +346,33 @@ func NewCoreServiceWithDurableStores(store repo.Store, logStore LogBodyStore, ar
return service, nil
}
func (svc *CoreService) recoverJobLogStreams() error {
jobs, err := svc.store.Jobs().List(domain.JobFilter{})
if err != nil {
return err
}
stamp := svc.now()
for _, job := range jobs {
if strings.TrimSpace(job.ID) == "" || strings.TrimSpace(job.ServerInstanceID) == "" {
continue
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if errors.Is(err, repo.ErrNotFound) {
continue
}
if err != nil {
return err
}
if instance.State == domain.ServerInstanceStateDeleted {
continue
}
if err := svc.ensureJobLogStreams(job, stamp); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) recoverLogCursors() error {
store, ok := svc.logStore.(interface{ LatestSeq(string) (uint64, error) })
if !ok {
@@ -2247,6 +2277,9 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
existing, err := svc.store.Jobs().GetByIdempotency(job.RunEndpointID, job.IdempotencyKey)
if err == nil {
if err := svc.ensureJobLogStreams(existing, stamp); err != nil {
return domain.Job{}, err
}
return existing, nil
}
if !errors.Is(err, repo.ErrNotFound) {
@@ -2283,9 +2316,57 @@ func (svc *CoreService) CreateJob(job domain.Job) (domain.Job, error) {
if err := svc.store.Jobs().Create(job); err != nil {
return domain.Job{}, err
}
if err := svc.ensureJobLogStreams(job, stamp); err != nil {
return domain.Job{}, err
}
return domain.CopyJob(job), nil
}
func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) error {
if strings.TrimSpace(job.ServerInstanceID) == "" || strings.TrimSpace(job.ID) == "" {
return nil
}
streams := []struct {
key string
source domain.LogStreamSource
}{
{key: "stdout", source: domain.LogStreamSourceProcess},
{key: "stderr", source: domain.LogStreamSourceProcess},
}
if job.Capability == domain.JobCapabilityRemoteRunProgram {
streams = append(streams,
struct {
key string
source domain.LogStreamSource
}{key: "management-program.stdout", source: domain.LogStreamSourceManagementProgram},
struct {
key string
source domain.LogStreamSource
}{key: "management-program.stderr", source: domain.LogStreamSourceManagementProgram},
)
}
for _, item := range streams {
stream := domain.LogStream{
ID: jobLogStreamID(job.ID, item.key),
ServerInstanceID: job.ServerInstanceID,
Source: item.source,
StreamKey: item.key,
StorageBackend: domain.LogStorageBackendLocalSegments,
RetentionPolicy: "default",
CreatedAt: stamp,
UpdatedAt: stamp,
}
if _, err := svc.CreateLogStream(stream); err != nil && !errors.Is(err, repo.ErrDuplicate) {
return err
}
}
return nil
}
func jobLogStreamID(jobID string, streamKey string) string {
return fmt.Sprintf("job.%s.%s", jobID, streamKey)
}
func (svc *CoreService) GetJob(id string) (domain.Job, error) {
job, err := svc.store.Jobs().Get(id)
if err != nil {
+109 -2
View File
@@ -114,6 +114,10 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
if _, err := svc.GetJob(job.ID); err != nil {
t.Fatalf("get job: %v", err)
}
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil || len(streams) != 2 {
t.Fatalf("expected default job stdout/stderr streams, len=%d err=%v streams=%+v", len(streams), err, streams)
}
jobs, err := svc.ListJobs(domain.JobFilter{RunEndpointID: endpoint.ID})
if err != nil || len(jobs) != 1 {
t.Fatalf("list jobs: len=%d err=%v", len(jobs), err)
@@ -154,8 +158,8 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
if _, err := svc.GetLogStream(stream.ID); err != nil {
t.Fatalf("get log stream: %v", err)
}
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil || len(streams) != 1 {
streams, err = svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil || len(streams) != 3 {
t.Fatalf("list log streams: len=%d err=%v", len(streams), err)
}
@@ -183,6 +187,109 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) {
}
}
func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProgram)
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin capabilities: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProgram)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update endpoint capabilities: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-terminal",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "SCUM Terminal",
})
if err != nil {
t.Fatalf("create server instance: %v", err)
}
job, err := svc.CreateJob(domain.Job{
ID: "job-terminal",
ServerInstanceID: instance.ID,
RunEndpointID: endpoint.ID,
Capability: domain.JobCapabilityRemoteRunProgram,
TargetKey: "protected-program",
InputRef: "input://protected-program/job-terminal",
IdempotencyKey: "idem-terminal",
})
if err != nil {
t.Fatalf("create remote program job: %v", err)
}
streams, err := svc.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list log streams: %v", err)
}
if len(streams) != 4 {
t.Fatalf("expected stdout/stderr plus management program streams, got %+v", streams)
}
want := map[string]domain.LogStreamSource{
"stdout": domain.LogStreamSourceProcess,
"stderr": domain.LogStreamSourceProcess,
"management-program.stdout": domain.LogStreamSourceManagementProgram,
"management-program.stderr": domain.LogStreamSourceManagementProgram,
}
for _, stream := range streams {
source, ok := want[stream.StreamKey]
if !ok {
t.Fatalf("unexpected stream key: %+v", stream)
}
if stream.Source != source || stream.ID != jobLogStreamID(job.ID, stream.StreamKey) {
t.Fatalf("unexpected stream metadata: %+v", stream)
}
delete(want, stream.StreamKey)
}
if len(want) != 0 {
t.Fatalf("missing streams: %+v", want)
}
}
func TestCoreServiceStartupRecoversLegacyJobLogStreams(t *testing.T) {
store := repo.NewMemoryStore()
seed := newCoreService(store, func() time.Time { return fixedTime })
plugin, endpoint := createPluginAndRunEndpoint(t, seed)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProgram)
if err := seed.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin capabilities: %v", err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProgram)
if err := seed.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update endpoint capabilities: %v", err)
}
if _, err := seed.CreateServerInstance(domain.ServerInstance{ID: "legacy-terminal-server", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Legacy Terminal"}); err != nil {
t.Fatalf("create server instance: %v", err)
}
if err := store.Jobs().Create(domain.Job{
ID: "legacy-terminal-job",
ServerInstanceID: "legacy-terminal-server",
RunEndpointID: endpoint.ID,
Capability: domain.JobCapabilityRemoteRunProgram,
TargetKey: "protected-program",
InputRef: "input://protected-program/legacy-terminal-job",
IdempotencyKey: "legacy-terminal",
State: domain.JobStateQueued,
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
}); err != nil {
t.Fatalf("seed legacy job: %v", err)
}
before, err := seed.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: "legacy-terminal-server"})
if err != nil || len(before) != 0 {
t.Fatalf("expected no seeded streams before recovery, len=%d err=%v streams=%+v", len(before), err, before)
}
recovered, err := NewCoreServiceWithDurableStores(store, NewMemoryLogBodyStore(), NewMemoryArtifactBodyStore())
if err != nil {
t.Fatalf("recover durable service: %v", err)
}
after, err := recovered.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: "legacy-terminal-server"})
if err != nil || len(after) != 4 {
t.Fatalf("expected recovered job log streams, len=%d err=%v streams=%+v", len(after), err, after)
}
}
func TestCoreServiceRejectsInvalidServerDependencies(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
+13 -1
View File
@@ -68,7 +68,7 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
computed, err := LogEntriesChecksum(batch.Entries)
if err != nil {
violations = append(violations, "checksum cannot be computed")
} else if batch.Checksum != computed {
} else if batch.Checksum != computed && !logLineChecksumMatches(batch) {
violations = append(violations, "checksum does not match entries")
}
}
@@ -107,6 +107,18 @@ func LogEntriesChecksum(entries []domain.LogEntry) (string, error) {
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
func logLineChecksumMatches(batch domain.LogBatchIngest) bool {
if len(batch.Entries) != 1 {
return false
}
return batch.Checksum == LogLineChecksum(batch.Entries[0].Line)
}
func LogLineChecksum(value string) string {
sum := sha256.Sum256([]byte(value))
return "sha256:" + hex.EncodeToString(sum[:])
}
type logEntryChecksumBody struct {
Seq uint64 `json:"seq"`
Timestamp string `json:"timestamp"`
+1 -1
View File
@@ -2355,7 +2355,7 @@ func validArtifactState(state domain.ArtifactState) bool {
func validLogStreamSource(source domain.LogStreamSource) bool {
switch source {
case domain.LogStreamSourceProcess, domain.LogStreamSourceFile, domain.LogStreamSourcePlugin:
case domain.LogStreamSourceProcess, domain.LogStreamSourceFile, domain.LogStreamSourcePlugin, domain.LogStreamSourceManagementProgram:
return true
default:
return strings.TrimSpace(string(source)) != ""
@@ -1,4 +1,4 @@
import { Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -13,6 +13,12 @@ type TerminalLine = { id: string; tone: "input" | "info" | "success" | "error";
const liveLogPollMs = 2000;
const maxLogEntries = 500;
const terminalQuickCommands = [
{ label: "查询玩家", command: "ListPlayers" },
{ label: "服务器状态", command: "ServerInfo" },
{ label: "设为中午", command: "SetTime 12" },
{ label: "保存世界", command: "SaveWorld" }
];
interface LiveOperationDrawerProps {
open: boolean;
@@ -20,9 +26,12 @@ interface LiveOperationDrawerProps {
description?: string;
onClose: () => void;
children: ReactNode;
backdropClassName?: string;
panelClassName?: string;
bodyClassName?: string;
}
function LiveOperationDrawer({ open, title, description, onClose, children }: LiveOperationDrawerProps) {
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName }: LiveOperationDrawerProps) {
useEffect(() => {
if (!open) return undefined;
const previous = document.body.style.overflow;
@@ -39,8 +48,8 @@ function LiveOperationDrawer({ open, title, description, onClose, children }: Li
if (!open) return null;
return (
<div className="drawer-backdrop" role="presentation" onClick={onClose}>
<aside className="drawer-panel live-operation-drawer" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<div>
<h2>{title}</h2>
@@ -48,7 +57,7 @@ function LiveOperationDrawer({ open, title, description, onClose, children }: Li
</div>
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span></span></button>
</div>
{children}
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
</aside>
</div>
);
@@ -200,21 +209,31 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
}
return (
<LiveOperationDrawer open={open} title="管理终端" description={`${serverName} · 平台授权的一次性命令调度`} onClose={onClose}>
<div className="terminal-output" role="log" aria-live="polite">
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span>{line.text}</span></div>)}
</div>
{result && <ResultBadge status={result.status} label={result.label} />}
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
{supportsCommands && (
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
SCUM
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令,回车提交" : "当前账号没有运行操作权限"} onChange={(event) => setCommand(event.target.value)} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
</form>
)}
<LiveOperationDrawer open={open} title="管理终端" description={`${serverName} · 平台授权的一次性命令调度`} onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body">
<section className="terminal-output-panel" aria-label="terminal output">
<div className="terminal-output" role="log" aria-live="polite">
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span>{line.text}</span></div>)}
</div>
</section>
<section className="terminal-command-dock" aria-label="terminal command controls">
<div className="terminal-command-dock-header">
<span><ListChecks size={14} /></span>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<div className="terminal-quick-command-list">
{terminalQuickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!supportsCommands || !canManage || pending} onClick={() => setCommand(item.command)}><strong>{item.label}</strong><span>{item.command}</span></button>)}
</div>
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
{supportsCommands && (
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
SCUM
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令,回车提交" : "当前账号没有运行操作权限"} onChange={(event) => setCommand(event.target.value)} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
</form>
)}
</section>
</LiveOperationDrawer>
);
}
+8
View File
@@ -10,6 +10,7 @@ import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page";
@@ -289,6 +290,13 @@ describe("first-party console pages", () => {
expect(html).toContain("操作历史");
});
it("renders management terminal as a large command console with quick commands", () => {
expect(serverLiveOperationsSource).toContain("management-terminal-drawer");
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
expect(serverLiveOperationsSource).toContain("terminalQuickCommands");
expect(serverLiveOperationsSource).toContain("setCommand(item.command)");
});
it("renders plugin catalog bridge readiness", () => {
const html = renderToStaticMarkup(<PluginsPage />);
+9
View File
@@ -165,6 +165,15 @@ describe("platform web shared theme CSS", () => {
expect(css).toContain("max-height:calc(100dvh-20px)");
});
it("keeps the management terminal opaque and large", () => {
const css = compact(readThemeCss());
expect(css).toContain(".management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh");
expect(css).toContain("background:#05090f;backdrop-filter:none");
expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto");
expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))");
});
it("keeps deployment workflow fields compact when a mode has one input", () => {
const css = compact(readThemeCss());
+18
View File
@@ -486,16 +486,32 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.drawer-backdrop{position:fixed;inset:0;z-index:40;background:rgba(61,36,65,.34);backdrop-filter:blur(6px);display:flex;justify-content:flex-end;overflow-y:auto;overscroll-behavior:contain}
.drawer-panel{width:min(440px,100%);height:100%;max-height:100dvh;display:grid;align-content:start;gap:14px;padding:20px;background:var(--glass-wash),var(--glass-tint),var(--surface-raised);backdrop-filter:blur(14px) saturate(0.28);border-left:1px solid var(--line);box-shadow:inset 1px 0 0 var(--crystal-rim),-18px 0 42px var(--glass-shadow);position:relative;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain}
.live-operation-drawer{width:min(720px,100%)}
.live-operation-content{display:grid;gap:12px;min-height:0}
.live-operation-toolbar .icon-command{min-height:36px}
.live-log-list{max-height:min(58dvh,560px)}
.live-log-line{cursor:default}
.terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:5dvh 0 0;background:rgba(2,6,10,.76);backdrop-filter:none}
.management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh;align-content:stretch;grid-template-rows:auto minmax(0,1fr);padding:16px 18px;border:1px solid var(--line-strong);border-bottom:0;border-radius:8px 8px 0 0;background:#05090f;backdrop-filter:none;box-shadow:0 -18px 48px rgba(0,0,0,.56),inset 0 1px 0 color-mix(in srgb,var(--rim-light) 24%,transparent)}
.management-terminal-drawer>.panel-header{top:-16px;margin:-16px -18px 0;padding:14px 18px 12px;background:#07101a;-webkit-backdrop-filter:none;backdrop-filter:none}
.management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr) auto;gap:12px}
.terminal-output-panel{display:grid;min-height:0}
.terminal-output{display:grid;gap:6px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:12px;border-radius:8px;background:radial-gradient(circle at 92% 0,rgba(255,255,255,.1),transparent 36%),var(--code-surface);font-family:var(--font-mono);font-size:12.5px;color:var(--code-ink);box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 14px 32px rgba(255,255,255,.12)}
.management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none}
.terminal-line{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;align-items:start}
.terminal-line time{color:var(--code-muted)}
.terminal-line span{overflow-wrap:anywhere}
.terminal-line-input span{color:var(--accent)}
.terminal-line-success span{color:var(--teal)}
.terminal-line-error span{color:var(--danger)}
.terminal-command-dock{display:grid;gap:10px;padding:12px;border:1px solid var(--line-strong);border-radius:8px;background:#08111b;box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 14px 32px rgba(0,0,0,.28)}
.terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px}
.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:var(--ink);font-weight:800}
.terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
.terminal-quick-command{display:grid;gap:4px;min-height:52px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#0b1722;color:var(--ink-soft);cursor:pointer;text-align:left}
.terminal-quick-command:hover,.terminal-quick-command:focus-visible{border-color:var(--accent);outline:0;color:var(--ink)}
.terminal-quick-command:disabled{cursor:not-allowed;opacity:.56}
.terminal-quick-command strong{font-size:12px;color:var(--ink)}
.terminal-quick-command span{font-family:var(--font-mono);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.terminal-command-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end}
.terminal-command-form label{display:grid;gap:6px;color:var(--ink-soft);font-size:12px}
.terminal-command-form input{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}
@@ -695,6 +711,8 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-detail-title-row .action-strip>*{flex:1 1 auto}
.provider-table,.resource-table{min-width:680px}
.log-line{grid-template-columns:minmax(0,1fr);gap:2px}
.terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))}
.management-terminal-drawer{height:92dvh;max-height:92dvh}
.ai-provider-toolbar{align-items:stretch}
.icon-command,.segmented-button{flex:1 1 auto}
.provider-form{order:-1}