Repair live server operations console

This commit is contained in:
npc0-hue
2026-08-03 11:21:07 +08:00
parent 9f82e310b2
commit 1d5fb586d0
16 changed files with 594 additions and 134 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-03
@@ -0,0 +1,50 @@
## Context
Server cards and server detail headers already consume `GET /api/v1/metrics/server-instances`, but the Platform service currently synthesizes online server values from instance identity and config version when no Run sample exists. The repository already has durable metric sample ingest/query, log stream metadata, cursor-based log queries, job polling, and a SCUM Source RCON dispatch path. The change should connect those existing primitives to the operator UI without adding raw shell access or leaking machine details.
## Goals / Non-Goals
**Goals:**
- Make per-server metrics represent the latest real Run sample or a clearly empty/stale state.
- 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.
- Keep logs and commands readable in the existing game-operations theme and shared console primitives.
**Non-Goals:**
- No browser-direct shell, raw socket console, host-path display, credential display, or Run endpoint address display.
- No new run executor source tree, cloud-provider workflow, billing, or marketplace expansion.
- No platform-side game-specific command execution beyond routing through existing plugin/platform-mediated capabilities such as Source RCON.
## Decisions
### Decision 1: Latest sample beats synthetic projection
`ListServerMetricsForSession` will select the most recent persisted `MetricSample` for each visible server. If no sample exists, the response returns only safe identity/online/source/timestamp metadata with no fabricated player, TPS, latency, or resource percentages. The UI will render `--`, `等待上报`, or `指标过期` based on sample freshness.
Alternative considered: keep deterministic placeholders and label them as simulated. This still makes the server card look operational when it is not, so it is rejected.
### Decision 2: Polling over new transports
The frontend will use bounded polling: server cards refresh metrics/jobs every few seconds, detail headers refresh the same projections, and live logs tail with `/log-streams/query` using `afterSeq`. This reuses existing routes and avoids adding WebSocket/SSE infrastructure in the same change.
Alternative considered: introduce WebSockets for true streaming. That is better long-term, but it would require new transport contracts and channel ownership beyond this targeted repair.
### 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.
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
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.
Alternative considered: keep overview but empty it down. That preserves a tab the user already identified as low-value, so it is removed.
## Risks / Trade-offs
- [Risk] Existing local/demo data may have no metric samples and therefore show fewer numbers. → Mitigation: render clear pending/stale labels and retain manual refresh.
- [Risk] Polling can create noisy requests on many cards. → Mitigation: use bounded intervals, reuse existing list endpoints, and clear timers on unmount.
- [Risk] Operators may expect a raw terminal. → Mitigation: label it `管理终端`, show the mediated command target, and make unsupported/direct-shell boundaries explicit in the UI.
- [Risk] CSS changes could drift from the theme. → Mitigation: use shared console/drawer/button primitives and keep CSS declarations compact per repository rules.
@@ -0,0 +1,26 @@
## Why
Server management currently presents live-looking server values that are not live: per-server players, TPS, latency, CPU, memory, and disk are projected from deterministic platform placeholders when no Run metric sample exists. Operators also need fast log and command access from the server list without losing the safe Platform-mediated boundaries.
## What Changes
- Replace placeholder per-server metrics with latest authorized Run metric samples, including clear empty/stale states when no fresh sample exists.
- Add automatic refresh for server list and server detail operational data so cards, task status, and detail headers do not remain frozen until manual refresh.
- 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.
- 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
### New Capabilities
- `server-live-operations-console`: Real server metric projection, refresh cadence, live log tailing, and safe management terminal behavior for 服务器管理.
### Modified Capabilities
- None.
## Impact
- Affected roots: `platform/` for per-server metric projection, `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`.
@@ -0,0 +1,67 @@
## ADDED Requirements
### Requirement: Server metrics use real samples
The system SHALL display per-server players, TPS, latency, CPU, memory, and disk from the latest authorized Run metric sample, and SHALL NOT fabricate live-looking values when no sample exists.
#### Scenario: Fresh metric sample exists
- **WHEN** a visible server has a latest Run metric sample
- **THEN** the server list and detail header show that sample's players, TPS, latency, CPU, memory, and disk values with the sample collection time available to the UI
#### Scenario: No metric sample exists
- **WHEN** a visible server has no persisted metric sample
- **THEN** the server list and detail header show pending or placeholder states instead of deterministic player, TPS, latency, CPU, memory, or disk values
#### Scenario: Stale metric sample exists
- **WHEN** a visible server's latest metric sample is older than the frontend freshness threshold
- **THEN** the UI marks the metrics as stale while preserving the last safe values and collection time
### Requirement: Server operations refresh automatically
The system SHALL refresh server operational projections on a bounded cadence instead of requiring manual refresh for every data update.
#### Scenario: Server list is open
- **WHEN** an operator is viewing the server list
- **THEN** metrics and task/job status refresh automatically at a bounded interval and stop refreshing when the page unmounts
#### Scenario: Server detail is open
- **WHEN** an operator is viewing a server detail workspace
- **THEN** the header metrics and job state refresh automatically at a bounded interval and stop refreshing when the page unmounts
### Requirement: Server list exposes live operation entry points
The server list SHALL keep `编辑部署` on server cards and SHALL provide direct live log and management terminal entry points without moving deployment editing into the detail page.
#### Scenario: Server card actions render
- **WHEN** an operator views a server card
- **THEN** the card includes `编辑部署`, `实时日志`, and `管理终端` actions using compact controls that do not reflow the card into a command tower
#### Scenario: Server detail actions render
- **WHEN** an operator views the server detail workspace
- **THEN** the detail header does not render an `编辑部署` action or deployment save workflow
### Requirement: Live logs tail through platform cursors
The live log window SHALL read platform log streams and entries through authorized platform APIs using bounded cursor polling.
#### Scenario: Live log drawer opens
- **WHEN** an operator opens `实时日志` for a server
- **THEN** the UI lists authorized log streams for that server and tails selected stream entries using `afterSeq` cursor polling
#### Scenario: Live log controls are used
- **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
### 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.
#### Scenario: SCUM command submitted
- **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: 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
### Requirement: Detail overview is removed
The server detail workspace SHALL omit the low-value overview panel and start from operational sections.
#### Scenario: Detail navigation renders
- **WHEN** an operator opens server detail
- **THEN** the section navigation excludes `概览` and provides operational sections such as logs, runtime controls, configuration, plugin controls, AI assistant, and operation history
@@ -0,0 +1,26 @@
## 1. Backend Metrics Projection
- [x] 1.1 Update per-server metrics listing to return the latest authorized persisted metric sample instead of deterministic placeholder values.
- [x] 1.2 Add/adjust service tests for latest-sample projection and no-sample placeholder behavior.
## 2. Frontend Refresh And Metrics Presentation
- [x] 2.1 Add bounded automatic refresh for server list metrics/jobs and server detail header/job projections.
- [x] 2.2 Update server cards and detail header to show metric freshness, placeholders, and CPU/memory/disk progress meters.
## 3. Live Logs And Management Terminal
- [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.
## 4. Server Detail Workspace
- [x] 4.1 Remove the server detail overview section/tab and deployment edit button while keeping operational sections available.
- [x] 4.2 Reuse the live log and management terminal components from detail where appropriate.
## 5. Verification
- [x] 5.1 Run focused backend/frontend tests covering metrics, page contracts, and command/log UI.
- [x] 5.2 Run `scripts/check-structure.sh`.
- [x] 5.3 Run `openspec validate repair-server-live-operations-console --strict`.
+1 -1
View File
@@ -170,7 +170,7 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden) assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/metrics/platform", "", ownerSession), http.StatusForbidden, errorCodeForbidden)
ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession) ownerMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", ownerSession)
if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || !ownerMetrics.Items[0].Online { if ownerMetrics.Count != 1 || ownerMetrics.Items[0].ServerInstanceID != instance.ID || !ownerMetrics.Items[0].Online || ownerMetrics.Items[0].CPUPercent != nil || ownerMetrics.Items[0].Source != "run-metrics-pending" {
t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) t.Fatalf("unexpected owner metrics: %+v", ownerMetrics)
} }
otherMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", otherSession) otherMetrics := getJSONWithAuth[dto.ServerMetricsListResponse](t, router, "/api/v1/metrics/server-instances", otherSession)
+34 -23
View File
@@ -1805,7 +1805,7 @@ func (svc *CoreService) ListServerMetricsForSession(sessionID string) ([]domain.
} }
items := make([]domain.ServerMetrics, 0, len(instances)) items := make([]domain.ServerMetrics, 0, len(instances))
for _, instance := range instances { for _, instance := range instances {
items = append(items, svc.metricsForServer(instance)) items = append(items, svc.latestMetricsForServer(instance))
} }
if err := validator.ValidateServerMetricsList(items); err != nil { if err := validator.ValidateServerMetricsList(items); err != nil {
return nil, err return nil, err
@@ -2026,32 +2026,43 @@ func (svc *CoreService) runtimeProfileScope(serverInstanceID string) string {
return binding.ProfileKey return binding.ProfileKey
} }
func (svc *CoreService) metricsForServer(instance domain.ServerInstance) domain.ServerMetrics { func (svc *CoreService) latestMetricsForServer(instance domain.ServerInstance) domain.ServerMetrics {
metrics := domain.ServerMetrics{ samples, err := svc.store.MetricSamples().List(domain.MetricSampleFilter{ServerInstanceID: instance.ID})
if err == nil {
var latest domain.MetricSample
found := false
for i := range samples {
sample := samples[i]
if sample.RunEndpointID != "" && sample.RunEndpointID != instance.RunEndpointID {
continue
}
if !found || sample.CollectedAt.After(latest.CollectedAt) {
latest = sample
found = true
}
}
if found {
return domain.ServerMetrics{
ServerInstanceID: latest.ServerInstanceID,
Online: latest.Online,
PlayerCount: latest.PlayerCount,
MaxPlayers: latest.MaxPlayers,
TPS: latest.TPS,
LatencyMS: latest.LatencyMS,
CPUPercent: latest.CPUPercent,
MemoryPercent: latest.MemoryPercent,
DiskPercent: latest.DiskPercent,
Source: latest.Source,
CollectedAt: latest.CollectedAt,
}
}
}
return domain.ServerMetrics{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
Online: instance.State == domain.ServerInstanceStateRunning, Online: instance.State == domain.ServerInstanceStateRunning,
Source: "platform-derived", Source: "run-metrics-pending",
CollectedAt: svc.now(), CollectedAt: svc.now(),
} }
if !metrics.Online {
return metrics
}
seed := len(instance.ID) + len(instance.Name) + instance.ConfigVersion
playerCount := seed % 20
maxPlayers := 20
tps := 18.5 + float64(seed%15)/10
latency := 35.0 + float64(seed%40)
cpu := clampPercent(25 + float64(seed%45))
memory := clampPercent(30 + float64(seed%50))
disk := clampPercent(20 + float64(seed%60))
metrics.PlayerCount = &playerCount
metrics.MaxPlayers = &maxPlayers
metrics.TPS = &tps
metrics.LatencyMS = &latency
metrics.CPUPercent = &cpu
metrics.MemoryPercent = &memory
metrics.DiskPercent = &disk
return metrics
} }
func buildLogicalServerConfig(instance domain.ServerInstance) string { func buildLogicalServerConfig(instance domain.ServerInstance) string {
+30 -2
View File
@@ -557,8 +557,36 @@ func TestCoreServiceMetricsAndConfigReadAreRoleScoped(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("list owner metrics: %v", err) t.Fatalf("list owner metrics: %v", err)
} }
if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || !ownerMetrics[0].Online || ownerMetrics[0].CPUPercent == nil { if len(ownerMetrics) != 1 || ownerMetrics[0].ServerInstanceID != instance.ID || !ownerMetrics[0].Online || ownerMetrics[0].CPUPercent != nil || ownerMetrics[0].Source != "run-metrics-pending" {
t.Fatalf("unexpected owner metrics: %+v", ownerMetrics) t.Fatalf("expected pending metrics without fabricated resource values, got %+v", ownerMetrics)
}
runHello := validRunControlHello()
runHello.RunEndpointID = endpoint.ID
registered, err := svc.RegisterRunHello(runHello)
if err != nil {
t.Fatalf("register run: %v", err)
}
oldCPU := 28.0
latestCPU := 64.0
latestMemory := 52.0
latestDisk := 31.0
players := 11
maxPlayers := 40
tps := 19.7
latency := 48.0
if _, err := svc.IngestMetricBatch(domain.MetricBatchIngest{RunEndpointID: endpoint.ID, SessionToken: registered.SessionToken, Samples: []domain.MetricSample{
{ServerInstanceID: instance.ID, Online: true, CPUPercent: &oldCPU, Source: "run", CollectedAt: fixedTime.Add(-time.Minute)},
{ServerInstanceID: instance.ID, Online: true, PlayerCount: &players, MaxPlayers: &maxPlayers, TPS: &tps, LatencyMS: &latency, CPUPercent: &latestCPU, MemoryPercent: &latestMemory, DiskPercent: &latestDisk, Source: "run", CollectedAt: fixedTime.Add(time.Minute)},
}}); err != nil {
t.Fatalf("ingest metrics: %v", err)
}
ownerMetrics, err = svc.ListServerMetricsForSession(ownerSession)
if err != nil {
t.Fatalf("list owner metrics after ingest: %v", err)
}
if len(ownerMetrics) != 1 || ownerMetrics[0].CPUPercent == nil || *ownerMetrics[0].CPUPercent != latestCPU || ownerMetrics[0].PlayerCount == nil || *ownerMetrics[0].PlayerCount != players || ownerMetrics[0].CollectedAt != fixedTime.Add(time.Minute) {
t.Fatalf("expected latest persisted sample metrics, got %+v", ownerMetrics)
} }
otherMetrics, err := svc.ListServerMetricsForSession(otherSession) otherMetrics, err := svc.ListServerMetricsForSession(otherSession)
if err != nil { if err != nil {
@@ -0,0 +1,227 @@
import { 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";
import type { LogEntryBody, LogStreamResponse } from "../api/types";
import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
import { cx } from "../utils/classes";
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" | "error"; text: string; at: string };
const liveLogPollMs = 2000;
const maxLogEntries = 500;
interface LiveOperationDrawerProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
}
function LiveOperationDrawer({ open, title, description, onClose, children }: LiveOperationDrawerProps) {
useEffect(() => {
if (!open) return undefined;
const previous = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previous;
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, open]);
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="panel-header">
<div>
<h2>{title}</h2>
{description && <p className="page-status">{description}</p>}
</div>
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span></span></button>
</div>
{children}
</aside>
</div>
);
}
interface ServerLiveLogDrawerProps {
open: boolean;
serverId: string;
serverName: string;
onClose: () => void;
}
export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) {
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 loadStreams = useCallback(async () => {
if (!open) return;
setStreams({ status: "loading" });
try {
const response = await platformApiClient.listServerLiveLogs(serverId);
setStreams({ status: "ready", data: response.items });
setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? "");
} catch (error) {
setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" });
}
}, [open, serverId]);
useEffect(() => {
if (!open) return;
setEntries([]);
setCursorByStream({});
setPaused(false);
void loadStreams();
}, [loadStreams, open]);
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] ?? 0;
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
setCursorByStream((current) => ({ ...current, [selectedStream.id]: Math.max(current[selectedStream.id] ?? 0, cursor.nextSeq, cursor.latestSeq) }));
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)));
}, [entries, keyword, selectedStreamId]);
function clearVisibleBuffer() {
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
}
return (
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
<div className="log-filter-bar live-operation-toolbar">
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => { setSelectedStreamId(event.target.value); setEntries([]); setCursorByStream((current) => ({ ...current, [event.target.value]: 0 })); }}>
{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={clearVisibleBuffer}><Trash2 size={14} /><span></span></button>
</div>
<span className="page-status">{paused ? "已暂停" : "自动刷新"} · {lastRefreshAt || "等待"} · {selectedStream ? cursorByStream[selectedStream.id] ?? 0 : "--"}</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="没有新的匹配日志;保持窗口打开会继续按游标刷新。" />}
{visibleEntries.length > 0 && (
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
{visibleEntries.map((entry) => (
<div key={`${entry.streamId}-${entry.seq}`} className="log-line live-log-line">
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
</div>
))}
</div>
)}
</LiveOperationDrawer>
);
}
interface ServerManagementTerminalDrawerProps {
open: boolean;
serverId: string;
serverName: string;
pluginId: string;
canManage: boolean;
onClose: () => void;
}
export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) {
const [command, setCommand] = useState("");
const [pending, setPending] = useState(false);
const [lines, setLines] = useState<TerminalLine[]>([]);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const supportsCommands = pluginId === "game.scum";
useEffect(() => {
if (!open) return;
setCommand("");
setPending(false);
setResult(null);
setLines([{ id: `open-${Date.now()}`, tone: "info", text: supportsCommands ? "SCUM 管理终端已连接到平台 Source RCON 调度通道。" : "该插件暂未声明可用的管理终端命令通道。", at: new Date().toLocaleTimeString() }]);
}, [open, supportsCommands]);
async function submitCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!supportsCommands || !canManage || pending || !command.trim()) return;
const submitted = command.trim();
setCommand("");
setPending(true);
setResult({ status: "pending", label: "正在提交命令" });
setLines((current) => [...current, { id: `input-${Date.now()}`, tone: "input", text: `> ${submitted}`, at: new Date().toLocaleTimeString() }]);
try {
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
const label = `已排队 · 任务 ${response.jobId}`;
setResult({ status: "succeeded", label });
setLines((current) => [...current, { id: `ok-${response.jobId}`, tone: "success", text: `${label} · ${response.message || response.status}`, at: new Date().toLocaleTimeString() }]);
} catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败";
setResult({ status: "failed", label });
setLines((current) => [...current, { id: `err-${Date.now()}`, tone: "error", text: label, at: new Date().toLocaleTimeString() }]);
} finally {
setPending(false);
}
}
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>
);
}
function levelClass(level?: string): string {
const normalized = (level ?? "info").toLowerCase();
if (normalized === "error" || normalized === "fatal") return "log-level-error";
if (normalized === "warn" || normalized === "warning") return "log-level-warn";
return "log-level-info";
}
+1 -1
View File
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
## 服务器详情 ## 服务器详情
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk, plus confirmed start/stop lifecycle actions. Sections: 概览 (live status cards, warnings), 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons). Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, confirmed start/stop lifecycle actions, and direct live-log/management-terminal entry points. Sections: 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 管理终端 (platform-mediated plugin command surface), 运行操作 (run binding, read-only deployment status, distribution, lifecycle, members), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons).
## 插件市场 ## 插件市场
+3 -2
View File
@@ -143,11 +143,12 @@ export interface PlatformOverviewSignal {
at: string; at: string;
} }
export type ServerDetailSection = "overview" | "logs" | "config" | "plugins" | "llm" | "history"; export type ServerDetailSection = "logs" | "terminal" | "runtime" | "config" | "plugins" | "llm" | "history";
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [ export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "overview", label: "概览" },
{ id: "logs", label: "日志" }, { id: "logs", label: "日志" },
{ id: "terminal", label: "管理终端" },
{ id: "runtime", label: "运行操作" },
{ id: "config", label: "配置" }, { id: "config", label: "配置" },
{ id: "plugins", label: "插件控制" }, { id: "plugins", label: "插件控制" },
{ id: "llm", label: "AI 助手" }, { id: "llm", label: "AI 助手" },
+5 -2
View File
@@ -172,7 +172,8 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain("<ServerDeploymentWorkflow"); expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
expect(serversPageSource).toContain("openEditDeployment"); expect(serversPageSource).toContain("openEditDeployment");
expect(serversPageSource).toContain("编辑部署"); expect(serversPageSource).toContain("编辑部署");
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow"); expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow");
expect(serverDetailPageSource).toContain("ServerDeploymentSection");
expect(serverDeploymentWorkflowSource).toContain("基本信息"); expect(serverDeploymentWorkflowSource).toContain("基本信息");
expect(serverDeploymentWorkflowSource).toContain("部署方式"); expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置"); expect(serverDeploymentWorkflowSource).toContain("相关配置");
@@ -278,8 +279,10 @@ describe("first-party console pages", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />); const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
expect(html).toContain("返回列表"); expect(html).toContain("返回列表");
expect(html).toContain("概览"); expect(html).not.toContain("概览");
expect(html).toContain("日志"); expect(html).toContain("日志");
expect(html).toContain("管理终端");
expect(html).toContain("运行操作");
expect(html).toContain("配置"); expect(html).toContain("配置");
expect(html).toContain("插件控制"); expect(html).toContain("插件控制");
expect(html).toContain("AI 助手"); expect(html).toContain("AI 助手");
+63 -94
View File
@@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ScrollText, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
@@ -21,14 +21,12 @@ import type {
ServerMemberResponse, ServerMemberResponse,
ServerMetricsResponse, ServerMetricsResponse,
RuntimeBindingResponse, RuntimeBindingResponse,
RunEndpointResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
ServerRuntimeActionsResponse, ServerRuntimeActionsResponse,
MetricSampleResponse, MetricSampleResponse,
RemoteAdapterDeclarationResponse RemoteAdapterDeclarationResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel"; import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench"; import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
@@ -50,7 +48,8 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge }
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation"; import { jobCapabilityLabel } from "../contracts/jobPresentation";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge"; import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { import {
serverDetailSections, serverDetailSections,
serverIsOnline, serverIsOnline,
@@ -79,10 +78,12 @@ import { stateLabel, statusClass } from "./ServersPage";
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 };
const defaultConfigKey = "server.properties"; const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) { export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("overview"); const [section, setSection] = useState<ServerDetailSection>("logs");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" }); const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null); const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]); const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
@@ -94,8 +95,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" }); const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" }); const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" }); const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]); const [liveLogOpen, setLiveLogOpen] = useState(false);
const [showDeploymentEditor, setShowDeploymentEditor] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null); const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
@@ -106,10 +107,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
} }
setInstance({ status: "loading" }); setInstance({ status: "loading" });
try { try {
const [detail, pluginResponse, endpointResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([ const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(), platformApiClient.listGamePlugins(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
platformApiClient platformApiClient
.getServerRuntimeActions(serverId) .getServerRuntimeActions(serverId)
@@ -129,7 +129,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items); setPlugins(pluginResponse.items);
setEndpoints(endpointResponse.items);
setJobs(jobResponse.items); setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse); setRuntimeActions(runtimeResponse);
setRuntimeBinding(bindingResponse); setRuntimeBinding(bindingResponse);
@@ -168,6 +167,27 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
const refreshOperationalState = useCallback(async () => {
if (!serverId) return;
try {
const [detail, jobResponse, metricsResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listJobs(serverId),
platformApiClient.listServerMetrics()
]);
setInstance({ status: "ready", data: detail });
setJobs(jobResponse.items);
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
} catch {
setMetrics(null);
}
}, [serverId]);
useEffect(() => {
const timer = window.setInterval(() => void refreshOperationalState(), serverDetailRefreshMs);
return () => window.clearInterval(timer);
}, [refreshOperationalState]);
useEffect(() => { useEffect(() => {
if (params.routeKey !== "run-builder" || instance.status !== "ready" || typeof document === "undefined") return; if (params.routeKey !== "run-builder" || instance.status !== "ready" || typeof document === "undefined") return;
const frame = window.requestAnimationFrame(() => { const frame = window.requestAnimationFrame(() => {
@@ -182,6 +202,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)), () => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
[operations.operations, serverId] [operations.operations, serverId]
); );
const canManageServers = session.capabilities.includes("servers.manage");
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") { function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
setConfirm({ setConfirm({
@@ -212,18 +233,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}); });
} }
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
if (instance.status !== "ready") return;
const current = instance.data;
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: current.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(current.id, { runEndpointId: form.runEndpointId || undefined, mode: form.deploymentMode, profileKey: form.profileKey || undefined, createInputs: form.createInputs, serverRoot: form.serverRoot.trim() || undefined, workingDirectory: form.workingDirectory.trim() || undefined, installCommand: form.installCommand.trim() || undefined, startCommand: form.startCommand.trim() || undefined, stopCommand: form.stopCommand.trim() || undefined, statusCommand: form.statusCommand.trim() || undefined, shell: form.shell || undefined });
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
setShowDeploymentEditor(false);
await refresh();
} catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败"); }
}
if (!serverId) { if (!serverId) {
return ( return (
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} /> <EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
@@ -294,7 +303,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
<Square size={15} /> <Square size={15} />
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" disabled={instance.data.state === "running" || instance.data.state === "installing"} title={instance.data.state === "running" || instance.data.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={() => setShowDeploymentEditor(true)}><Pencil size={15} /><span></span></button> <button type="button" className="icon-command" onClick={() => setLiveLogOpen(true)}><ScrollText size={15} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有运行操作权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span></span></button>
</div> </div>
</div> </div>
<div className="server-detail-stat-strip"> <div className="server-detail-stat-strip">
@@ -302,9 +312,12 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
<HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} /> <HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} />
<HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} /> <HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} />
<HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} /> <HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} />
<HeaderStat label="CPU" value={metrics?.cpuPercent !== undefined ? `${Math.round(metrics.cpuPercent)}%` : "--"} /> <HeaderStat label="指标" value={metricFreshnessLabel(metrics)} />
<HeaderStat label="内存" value={metrics?.memoryPercent !== undefined ? `${Math.round(metrics.memoryPercent)}%` : "--"} /> </div>
<HeaderStat label="磁盘" value={metrics?.diskPercent !== undefined ? `${Math.round(metrics.diskPercent)}%` : "--"} /> <div className="server-detail-meter-strip">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div> </div>
</header> </header>
@@ -322,8 +335,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
))} ))}
</nav> </nav>
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />} {section === "logs" && <LogsSection serverId={serverId} />}
{section === "overview" && ( {section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "runtime" && (
<RuntimeBindingSection <RuntimeBindingSection
instance={instance.data} instance={instance.data}
plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)} plugin={plugins.find((plugin) => plugin.id === instance.data.pluginId)}
@@ -333,10 +347,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()} onChanged={() => void refresh()}
/> />
)} )}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} onEdit={() => setShowDeploymentEditor(true)} />} {section === "runtime" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />} {section === "runtime" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />} {section === "runtime" && (
{section === "overview" && (
<RuntimeDistributionSection <RuntimeDistributionSection
instance={instance.data} instance={instance.data}
runtimeActions={runtimeActions} runtimeActions={runtimeActions}
@@ -346,10 +359,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()} onChanged={() => void refresh()}
/> />
)} )}
{section === "overview" && ( {section === "runtime" && <ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />}
<ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} /> {section === "runtime" && (
)}
{section === "overview" && (
<ServerMetadataSection <ServerMetadataSection
instance={instance.data} instance={instance.data}
session={session} session={session}
@@ -357,13 +368,13 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={(next) => setInstance({ status: "ready", data: next })} onChanged={(next) => setInstance({ status: "ready", data: next })}
/> />
)} )}
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />} {section === "runtime" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />} {section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />} {section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onReveal={() => platformApiClient.revealServerDeployment(instance.data.id)} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} /> <ServerLiveLogDrawer open={liveLogOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setLiveLogOpen(false)} />
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</> </>
)} )}
@@ -455,10 +466,9 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
interface ServerDeploymentSectionProps { interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>; deployment: LoadState<ServerDeploymentResponse>;
onEdit: () => void;
} }
function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploymentSectionProps) { function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) {
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />; if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />; if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data; const view = deployment.data;
@@ -469,7 +479,6 @@ function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploym
<p className="section-copy"></p> <p className="section-copy"></p>
<div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div> <div className="console-row-list"><div className="console-row"><span></span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span></span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span></span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span> Run </span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run </span><strong> r{view.latestDispatch.deploymentRevision} </strong></div>}</div>
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>} {isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM </span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span> / </span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span></span><strong>{projection.failureCode}</strong></div>}</div>}
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span></span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help"></span>}</div>
</article>; </article>;
} }
@@ -486,11 +495,6 @@ function deploymentProjectionLabel(value?: string): string {
} }
} }
function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
const plugin = plugins.find((item) => item.id === instance.pluginId);
return { ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment?.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment?.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment?.mode ?? "guided-install", shell: deployment?.shell ?? "" };
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string { function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) { switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取"; case "queued": return "任务已排队,等待 Run 领取";
@@ -644,51 +648,11 @@ function HeaderStat({ label, value }: { label: string; value: string }) {
); );
} }
interface OverviewSectionProps { function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
instance: ServerInstanceResponse; if (!metrics || metrics.source === "run-metrics-pending") return "等待上报";
metrics: ServerMetricsResponse | null; const collectedAt = new Date(metrics.collectedAt).getTime();
jobs: JobResponse[]; if (Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs) return "指标过期";
onOpenLogs: () => void; return new Date(metrics.collectedAt).toLocaleTimeString();
}
function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectionProps) {
const pending = jobs.filter((job) => job.state === "queued" || job.state === "accepted" || job.state === "running" || job.state === "retrying");
const failed = jobs.filter((job) => job.state === "failed");
return (
<div className="overview-two-col">
<article className="console-panel">
<div className="panel-header">
<h2>使</h2>
<span className="page-status">{metrics ? new Date(metrics.collectedAt).toLocaleTimeString() : "暂无指标"}</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</article>
<article className="console-panel">
<div className="panel-header">
<h2></h2>
<button type="button" className="icon-command" onClick={onOpenLogs}>
</button>
</div>
<div className="action-list">
{instance.state === "failed" && <span> </span>}
{failed.length > 0 && <span> {failed.length} </span>}
{pending.length > 0 ? (
<span>
{pending[0].capability}{deploymentProgressLabel(pending[0].progress)}{pending[0].progress.percent}%
</span>
) : (
<span></span>
)}
<span>v{instance.configVersion} {new Date(instance.updatedAt).toLocaleString()}</span>
</div>
</article>
</div>
);
} }
interface RuntimeDistributionSectionProps { interface RuntimeDistributionSectionProps {
@@ -1434,8 +1398,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" }); const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
const [selected, setSelected] = useState<(LogEntryBody & { source: string }) | null>(null); const [selected, setSelected] = useState<(LogEntryBody & { source: string }) | null>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async (showLoading = true) => {
setStreams({ status: "loading" }); if (showLoading) setStreams({ status: "loading" });
try { try {
const response = await platformApiClient.listServerLiveLogs(serverId); const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items; const serverStreams = response.items;
@@ -1460,6 +1424,11 @@ function LogsSection({ serverId }: LogsSectionProps) {
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
useEffect(() => {
const timer = window.setInterval(() => void refresh(false), 2000);
return () => window.clearInterval(timer);
}, [refresh]);
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]); const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
const visible = useMemo(() => { const visible = useMemo(() => {
+42 -8
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react"; import { AlertTriangle, CakeSlice, Candy, ScrollText, Search, Sparkles, Terminal, Trash2 } from "lucide-react";
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -16,6 +16,7 @@ import {
runtimeUpdateStages, runtimeUpdateStages,
useRuntimeTaskController useRuntimeTaskController
} from "../components/RuntimeTaskProgress"; } from "../components/RuntimeTaskProgress";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow"; import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
@@ -57,6 +58,9 @@ const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
{ id: "attention", label: "需关注" } { id: "attention", label: "需关注" }
]; ];
const serverListRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) { export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
const [listState, setListState] = useState<ListState>("loading"); const [listState, setListState] = useState<ListState>("loading");
const [listError, setListError] = useState<string>(""); const [listError, setListError] = useState<string>("");
@@ -78,9 +82,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [deletePassword, setDeletePassword] = useState(""); const [deletePassword, setDeletePassword] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null); const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
const [liveLogTarget, setLiveLogTarget] = useState<ServerInstanceResponse | null>(null);
const [terminalTarget, setTerminalTarget] = useState<ServerInstanceResponse | null>(null);
const refreshList = useCallback(async () => { const refreshList = useCallback(async (showLoading = true) => {
setListState("loading"); if (showLoading) setListState("loading");
try { try {
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([ const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
platformApiClient.listGamePlugins(), platformApiClient.listGamePlugins(),
@@ -92,7 +98,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setEndpoints(endpointResponse.items); setEndpoints(endpointResponse.items);
setInstances(instanceResponse.items); setInstances(instanceResponse.items);
setJobs(jobResponse.items); setJobs(jobResponse.items);
setForm((current) => { if (showLoading) setForm((current) => {
const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0]; const plugin = pluginResponse.items.find((item) => item.id === current.pluginId) ?? pluginResponse.items[0];
const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey) const profileKey = plugin?.runtimeProfiles?.lifecycleProfiles?.some((profile) => profile.key === current.profileKey)
? current.profileKey ? current.profileKey
@@ -107,10 +113,12 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
: endpointResponse.items[0]?.id || "" : endpointResponse.items[0]?.id || ""
}; };
}); });
setListState("ready"); if (showLoading) {
setListError(""); setListState("ready");
setListError("");
}
} catch (error) { } catch (error) {
setListState("error"); if (showLoading) setListState("error");
setListError(error instanceof Error ? error.message : "加载失败"); setListError(error instanceof Error ? error.message : "加载失败");
} }
}, []); }, []);
@@ -137,6 +145,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
void refresh(); void refresh();
}, [refresh]); }, [refresh]);
useEffect(() => {
const timer = window.setInterval(() => {
void refreshList(false);
void refreshMetrics();
}, serverListRefreshMs);
return () => window.clearInterval(timer);
}, [refreshList, refreshMetrics]);
const cards = useMemo<ServerCardView[]>( const cards = useMemo<ServerCardView[]>(
() => () =>
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({ summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
@@ -596,6 +612,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)} deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onEdit={() => void openEditDeployment(card.instance)} onEdit={() => void openEditDeployment(card.instance)}
onOpenLogs={() => setLiveLogTarget(card.instance)}
onOpenTerminal={() => setTerminalTarget(card.instance)}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)} onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => { onDelete={() => {
setDeletePassword(""); setDeletePassword("");
@@ -630,6 +648,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} /> <RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
<ServerLiveLogDrawer open={liveLogTarget !== null} serverId={liveLogTarget?.id ?? ""} serverName={liveLogTarget?.name ?? ""} onClose={() => setLiveLogTarget(null)} />
<ServerManagementTerminalDrawer open={terminalTarget !== null} serverId={terminalTarget?.id ?? ""} serverName={terminalTarget?.name ?? ""} pluginId={terminalTarget?.pluginId ?? ""} canManage={canManageServers} onClose={() => setTerminalTarget(null)} />
</section> </section>
); );
} }
@@ -674,15 +694,19 @@ interface ServerCardProps {
deleteDisabledReason: string; deleteDisabledReason: string;
onOpen: () => void; onOpen: () => void;
onEdit: () => void; onEdit: () => void;
onOpenLogs: () => void;
onOpenTerminal: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void; onQuickAction: (action: ServerQuickRuntimeAction) => void;
onDelete: () => void; onDelete: () => void;
} }
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) { function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onOpenLogs, onOpenTerminal, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card; const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state); const online = serverIsOnline(instance.state);
const canDelete = deleteDisabledReason === ""; const canDelete = deleteDisabledReason === "";
const canOpenActions = canManage || canDelete; const canOpenActions = canManage || canDelete;
const metricsWaiting = metrics?.source === "run-metrics-pending";
const metricsStale = isMetricsStale(metrics);
const menuButtonRef = useRef<HTMLButtonElement>(null); const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null); const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@@ -793,6 +817,8 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<UsageMeter label="磁盘" percent={metrics?.diskPercent} /> <UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div> </div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>} {metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{!metricsUnavailable && metricsWaiting && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{!metricsUnavailable && metricsStale && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} /></span>} {failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
<div className="action-strip" style={{ justifyContent: "space-between" }}> <div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}> <button type="button" className="icon-command" onClick={onOpen}>
@@ -800,6 +826,8 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span></span></button> <button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span></span></button>
<button type="button" className="icon-command" onClick={onOpenLogs}><ScrollText size={14} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "管理终端" : "当前账号没有运行操作权限"} onClick={onOpenTerminal}><Terminal size={14} /><span></span></button>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}> <button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span> <span></span>
</button> </button>
@@ -979,6 +1007,12 @@ function formatStat(value: number | undefined, pending: boolean, format: (value:
return pending ? "…" : "--"; return pending ? "…" : "--";
} }
function isMetricsStale(metrics?: ServerMetricsResponse): boolean {
if (!metrics || metrics.source === "run-metrics-pending") return false;
const collectedAt = new Date(metrics.collectedAt).getTime();
return Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs;
}
export function stateLabel(state: ServerInstanceResponse["state"]): string { export function stateLabel(state: ServerInstanceResponse["state"]): string {
switch (state) { switch (state) {
case "installing": case "installing":
+1 -1
View File
@@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation.
- `/`: 平台概览(平台管理员默认落地页). - `/`: 平台概览(平台管理员默认落地页).
- `/servers`: 服务器管理(服主/服务器管理员默认落地页). - `/servers`: 服务器管理(服主/服务器管理员默认落地页).
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:概览、日志、配置、插件控制、AI 助手、操作历史). - `/servers/:serverId`: 服务器详情 route(日常运维工作台:日志、管理终端、运行操作、配置、插件控制、AI 助手、操作历史).
- `/plugins`: 插件市场. - `/plugins`: 插件市场.
- `/users`: 用户管理. - `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理. - `/ai-providers`: AI 提供商管理.
+16
View File
@@ -465,6 +465,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-detail-title-row>div:first-child{min-width:0} .server-detail-title-row>div:first-child{min-width:0}
.server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere} .server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere}
.server-detail-stat-strip{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px} .server-detail-stat-strip{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px}
.server-detail-meter-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)} .section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap} .section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0} .section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
@@ -484,6 +485,20 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.log-line span:last-child{overflow-wrap:anywhere} .log-line span:last-child{overflow-wrap:anywhere}
.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-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} .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-toolbar .icon-command{min-height:36px}
.live-log-list{max-height:min(58dvh,560px)}
.live-log-line{cursor:default}
.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)}
.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-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}
.plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)} .plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)}
.management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)} .management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)}
.management-dialog-wide{width:min(920px,calc(100vw - 32px))} .management-dialog-wide{width:min(920px,calc(100vw - 32px))}
@@ -668,6 +683,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr} .catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr}
.form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr} .form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr}
.server-detail-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))} .server-detail-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
.server-detail-meter-strip,.terminal-command-form{grid-template-columns:1fr}
.client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr} .client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr}
.server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))} .server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px} .section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}