refactor(scum): remove orphaned host panels

This commit is contained in:
npc0-hue
2026-07-29 16:26:19 +08:00
parent 9fafd17915
commit 03339fb3e8
18 changed files with 31 additions and 1185 deletions
@@ -41,3 +41,33 @@ Consequently these tasks remain open and blocked rather than simulated:
No fallback to raw RCON, credentials, SQL, direct game-database access, OCR,
screenshots, keyboard/mouse injection, or desktop automation is permitted.
## 6.3 frontend cleanup and rollback evidence (2026-07-29)
The generic `PluginPageHostPage` has no hard-coded SCUM import or `game.scum`
branch. Static `rg` call-graph audit found zero non-test callers for the
following former `platform_web` implementation, so this batch removed it with
its private tests, contracts, schemas, API-client methods/types, and map-only
styles:
- `ScumOperationsPanel`, `ScumFileConfigWorkbench`, and the private
`scumOperations` contract/schema;
- `GamePlayerIntelligencePanel` and `GameGiftCatalogPanel`;
- `ScumMapTrajectoryPanel`.
Their plugin-owned replacement is `features/page.ts`, with typed bridge reads
and commands in `features/api.ts`, and the read-only provenance adapters in
`features/migration.ts`. The replacement remains capability-gated: missing
login/position event producers and missing state-patch/reward-delivery
handlers render unavailable controls and never synthesize records or enable a
fallback execution path.
The platform-side `/game-players`, `/game-gifts`, and `/game-map-trajectories`
routes, DTO/domain/model/service/repository implementations, and their
fixtures remain intentionally. `plugins/tests/fixtures/scum-migration-parity.ts`
continues to consume their historical record shapes through allowlisted,
`transitional-read-only` migration records. Rollback therefore consists of
disabling the exact server/version plugin authority flag, leaving those prior
records visible and read-only; it does not restore any deleted host panel or
re-enable an unsafe execution route. Backend removal remains blocked until 6.2
parity and the required versioned event/operation protocols exist.
@@ -45,3 +45,4 @@
## Verification evidence
- 5.6: `cd plugins/examples/scum-server-plugin/companion && go test ./...` passed on 2026-07-29. The isolated typed-port fixture covers safe configuration read/patch, `player.notify`, and version-bound `vehicle.spawn` through claim/ack/registry/complete; it checks approval, binding, version/capability gating, idempotency, bounded outcomes, and redaction. A production-source test rejects raw SQL/direct database access, unrestricted command/RCON execution, OCR/screenshot/input automation, and direct socket paths.
- 6.3 preparation: `platform_web` static call-graph audit on 2026-07-29 found that the legacy SCUM operation/config/player/gift/trajectory panels, their private contracts/schemas, and their game-player/gift/map client calls had no non-test caller after generic plugin-page hosting. The orphan frontend implementation was removed; the backend historical routes/models/services/repositories remain read-only migration input as documented in `implementation-blockers.md`. `cd platform_web && pnpm test && pnpm build`, `cd plugins && pnpm test && pnpm typecheck && pnpm validate:manifest`, `openspec validate move-scum-feature-ownership-to-plugin --strict`, and `scripts/check-structure.sh` passed.
-37
View File
@@ -48,21 +48,6 @@ import type {
GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse,
GamePluginListResponse,
GamePlayerListResponse,
GamePlayerProfileResponse,
GamePlayerStatePatchListResponse,
GamePlayerStatePatchRequest,
GamePlayerStatePatchResponse,
GamePlayerStateResponse,
GameMapTrajectoryResponse,
GameGiftCatalogListResponse,
GameGiftCatalogRequest,
GameGiftCatalogResponse,
GameGiftRevisionListResponse,
GameGiftRevisionResponse,
GameGiftGrantListResponse,
GameGiftGrantRequest,
GameGiftGrantResponse,
HealthResponse,
JobCreateRequest,
JobListResponse,
@@ -589,28 +574,6 @@ export class PlatformApiClient {
return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`);
}
async listGamePlayers(serverInstanceId: string, search = ""): Promise<GamePlayerListResponse> {
const query = search ? `?search=${encodeURIComponent(search)}` : "";
return this.request<GamePlayerListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players${query}`);
}
async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise<GamePlayerProfileResponse> {
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
}
async getGameMapTrajectories(serverInstanceId: string, filter: { from?: string; to?: string; playerIds?: string[]; vehicleIds?: string[] } = {}): Promise<GameMapTrajectoryResponse> { const params = new URLSearchParams(); if (filter.from) params.set("from", filter.from); if (filter.to) params.set("to", filter.to); if (filter.playerIds?.length) params.set("playerId", filter.playerIds.join(",")); if (filter.vehicleIds?.length) params.set("vehicleId", filter.vehicleIds.join(",")); const query = params.toString(); return this.request<GameMapTrajectoryResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-map-trajectories${query ? `?${query}` : ""}`); }
async getGamePlayerState(serverInstanceId: string, playerId: string): Promise<GamePlayerStateResponse> { return this.request<GamePlayerStateResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state`); }
async listGamePlayerStatePatches(serverInstanceId: string, playerId: string): Promise<GamePlayerStatePatchListResponse> { return this.request<GamePlayerStatePatchListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`); }
async requestGamePlayerStatePatch(serverInstanceId: string, playerId: string, request: GamePlayerStatePatchRequest): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches`, { method: "POST", body: request }); }
async approveGamePlayerStatePatch(serverInstanceId: string, playerId: string, patchId: string): Promise<GamePlayerStatePatchResponse> { return this.request<GamePlayerStatePatchResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}/state-patches/${encodeURIComponent(patchId)}/approve`, { method: "POST" }); }
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> { return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`); }
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> { return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request }); }
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> { return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST" }); }
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> { return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`); }
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> { return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`); }
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request }); }
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> { return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST" }); }
async getBackup(id: string): Promise<BackupResponse> {
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
}
-29
View File
@@ -310,35 +310,6 @@ export interface RuntimeLogEventResponse {
retentionDays: number;
severity: "info" | "notice" | "warning" | "critical";
}
export interface GamePlayerResponse { id: string; serverInstanceId: string; gamePlayerId: string; displayName: string; firstSeenAt: string; lastSeenAt: string; }
export interface GamePlayerAliasResponse { alias: string; firstSeenAt: string; lastSeenAt: string; }
export interface GamePlayerSessionResponse { id: string; sourceSessionId: string; startedAt: string; endedAt?: string; endReason?: string; }
export interface GameAccessAttemptResponse { id: string; occurredAt: string; outcome: string; reason: string; }
export interface GameSecuritySignalResponse { id: string; ruleKey: string; status: "open" | "review-required" | "expired"; evidenceCount: number; summary: string; firstObservedAt: string; lastObservedAt: string; }
export interface GamePlayerProfileResponse { player: GamePlayerResponse; aliases: GamePlayerAliasResponse[]; sessions: GamePlayerSessionResponse[]; accessAttempts: GameAccessAttemptResponse[]; securitySignals: GameSecuritySignalResponse[]; }
export interface GamePlayerListResponse { items: GamePlayerResponse[]; }
export interface GamePlayerStateFieldResponse { key: string; label: string; kind: "skill" | "attribute"; minimum: number; maximum: number; value: number; }
export interface GamePlayerStateResponse { gameVersion: string; stateVersion: string; safetyWindow?: string; maintenanceVerified: boolean; playerOnline: boolean; supported: boolean; fields: GamePlayerStateFieldResponse[]; observedAt: string; }
export interface GamePlayerStatePatchChangeRequest { fieldKey: string; before: number; after: number; }
export interface GamePlayerStatePatchRequest { gameVersion: string; expectedStateVersion: string; safetyWindow: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; }
export interface GamePlayerStatePatchResponse { id: string; gameVersion: string; expectedStateVersion: string; changes: GamePlayerStatePatchChangeRequest[]; reason: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "execution-failed" | "execution-unknown" | "confirmation-failed" | "confirmed"; bridgeCommandId?: string; executionSummary?: string; confirmedStateVersion?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GamePlayerStatePatchListResponse { items: GamePlayerStatePatchResponse[]; }
export interface GameMapTrajectoryPointResponse { mapX: number; mapY: number; occurredAt: string; collectedAt: string; source: "companion" | "log-projection"; }
export interface GameMapTrajectoryEntityResponse { kind: "player" | "vehicle"; entityId: string; gamePlayerRecordId?: string; label: string; points: GameMapTrajectoryPointResponse[]; collectedAt?: string; sources: string[]; }
export interface GameMapTrajectorySegmentResponse { gamePlayerRecordId: string; vehicleId: string; startedAt: string; endedAt?: string; }
export interface GameMapTrajectoryMapResponse { mapId: string; mapVersion: string; imageWidth: number; imageHeight: number; precision: number; }
export interface GameMapTrajectoryResponse { status: "ready" | "empty" | "missing-map"; reason?: string; map?: GameMapTrajectoryMapResponse; from?: string; to?: string; players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; rideSegments: GameMapTrajectorySegmentResponse[]; }
export interface GameGiftItemRequest { catalogItemKey: string; quantity: number; }
export interface GameGiftItemResponse extends GameGiftItemRequest { label: string; }
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: GameGiftItemRequest[]; }
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
export interface RuntimeTransportProfileResponse {
key: string;
kind: string;
@@ -1,12 +0,0 @@
import { describe, expect, it } from "vitest";
import source from "./GameGiftCatalogPanel.tsx?raw";
describe("GameGiftCatalogPanel", () => {
it("keeps lifecycle state readable and avoids raw game command surfaces", () => {
expect(source).toContain("版本化礼包目录与定向发放");
expect(source).toContain("通知失败");
expect(source).toContain("绝不自动重试");
expect(source).not.toContain("rawCommand");
expect(source).not.toContain("sendSourceRCONCommand");
});
});
@@ -1,25 +0,0 @@
import { CheckCircle2, Gift, RefreshCw, Send } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameGiftCatalogResponse, GameGiftGrantResponse, GameGiftRevisionResponse, GamePlayerResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
type Ready = { catalogs: GameGiftCatalogResponse[]; revisions: GameGiftRevisionResponse[]; grants: GameGiftGrantResponse[]; players: GamePlayerResponse[]; selectedCatalog?: string; selectedRevision?: string; selectedPlayer?: string; name: string; notice: string; item: "bandage" | "water-bottle" | "improvised-spear"; quantity: number; note?: string };
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: Ready };
const itemLabels = { bandage: "绷带", "water-bottle": "饮用水", "improvised-spear": "简易长矛" } as const;
export function GameGiftCatalogPanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" });
const load = useCallback(async () => { setState({ status: "loading" }); try { const [catalogs, grants, players] = await Promise.all([platformApiClient.listGameGiftCatalogs(serverInstanceId), platformApiClient.listGameGiftGrants(serverInstanceId), platformApiClient.listGamePlayers(serverInstanceId)]); const selectedCatalog = catalogs.items[0]?.id; const revisions = selectedCatalog ? await platformApiClient.listGameGiftRevisions(serverInstanceId, selectedCatalog) : { items: [] }; setState({ status: "ready", value: { catalogs: catalogs.items, revisions: revisions.items, grants: grants.items, players: players.items, selectedCatalog, selectedRevision: revisions.items[0]?.id, selectedPlayer: players.items[0]?.id, name: "新手补给", notice: "已向你发放运营礼包,请查收。", item: "bandage", quantity: 2 } }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "礼包目录读取失败" }); } }, [serverInstanceId]);
useEffect(() => { void load(); }, [load]);
const edit = (patch: Partial<Ready>) => setState((current) => current.status === "ready" ? { ...current, value: { ...current.value, ...patch } } : current);
async function createDraft() { if (state.status !== "ready") return; const v = state.value; try { const saved = await platformApiClient.saveGameGiftCatalog(serverInstanceId, { id: v.selectedCatalog, name: v.name, gameVersion: "0.9.700.90357", items: [{ catalogItemKey: v.item, quantity: v.quantity }] }); edit({ catalogs: [saved, ...v.catalogs.filter((catalog) => catalog.id !== saved.id)], selectedCatalog: saved.id, revisions: [], selectedRevision: undefined, note: "草稿已保存;发布后才可定向发放。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "草稿保存失败" }); } }
async function publish() { if (state.status !== "ready" || !state.value.selectedCatalog) return; try { const revision = await platformApiClient.publishGameGiftCatalog(serverInstanceId, state.value.selectedCatalog); edit({ revisions: [revision, ...state.value.revisions], selectedRevision: revision.id, note: "已发布不可变版本,可预览并选择目标玩家。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "发布失败" }); } }
async function requestGrant() { if (state.status !== "ready") return; const v=state.value; if (!v.selectedRevision || !v.selectedPlayer) return; try { const grant=await platformApiClient.requestGameGiftGrant(serverInstanceId,{revisionId:v.selectedRevision,gamePlayerRecordId:v.selectedPlayer,notice:v.notice,idempotencyKey:`gift-${v.selectedRevision}-${v.selectedPlayer}`}); edit({grants:[grant,...v.grants],note:"发放申请已冻结礼包版本和目标身份,等待平台管理员审批。"}); } catch(error){edit({note:error instanceof Error?error.message:"发放申请失败"});} }
async function approve(grant: GameGiftGrantResponse) { if(state.status!=="ready")return;try{const next=await platformApiClient.approveGameGiftGrant(serverInstanceId,grant.id);edit({grants:state.value.grants.map((item)=>item.id===next.id?next:item),note:"已通过受控游戏通道排队发放。"});}catch(error){edit({note:error instanceof Error?error.message:"审批失败"});} }
if(state.status==="loading")return <LoadingState label="正在加载版本化 SCUM 礼包目录…"/>;
if(state.status==="error")return <ErrorState title="礼包目录不可用" reason={state.reason} onRetry={()=>void load()}/>;
const v=state.value; const selectedRevision=v.revisions.find((item)=>item.id===v.selectedRevision);
return <section className="console-panel" aria-label="SCUM 版本化礼包目录与定向发放"><div className="panel-header"><div><h2><Gift size={16}/> </h2><p className="provider-id"> SCUM RCON </p></div><button className="icon-command" type="button" onClick={()=>void load()}><RefreshCw size={14}/><span></span></button></div><div className="console-module"><h3>稿</h3><div className="console-row-list"><label className="console-row"><strong></strong><input value={v.name} maxLength={80} onChange={(event)=>edit({name:event.target.value})}/></label><label className="console-row"><strong></strong><select value={v.item} onChange={(event)=>edit({item:event.target.value as Ready["item"]})}>{Object.entries(itemLabels).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></label><label className="console-row"><strong></strong><input type="number" min={1} max={20} value={v.quantity} onChange={(event)=>edit({quantity:Number(event.target.value)})}/></label></div><div className="action-strip"><button className="command-button" type="button" onClick={()=>void createDraft()}>稿</button><select aria-label="选择礼包草稿" value={v.selectedCatalog||""} onChange={async(event)=>{const catalogId=event.target.value;const response=await platformApiClient.listGameGiftRevisions(serverInstanceId,catalogId);edit({selectedCatalog:catalogId,revisions:response.items,selectedRevision:response.items[0]?.id});}}><option value="">稿</option>{v.catalogs.map((catalog)=><option key={catalog.id} value={catalog.id}>{catalog.name} · {catalog.gameVersion}</option>)}</select><button className="primary-command" type="button" disabled={!v.selectedCatalog} onClick={()=>void publish()}></button></div></div><div className="console-module"><h3></h3><p className="page-status">{selectedRevision ? `版本 #${selectedRevision.revision}${selectedRevision.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}` : "请先发布一个礼包版本。"}</p><div className="console-row-list"><label className="console-row"><strong></strong><select value={v.selectedPlayer||""} onChange={(event)=>edit({selectedPlayer:event.target.value})}>{v.players.map((player)=><option key={player.id} value={player.id}>{player.displayName}{player.gamePlayerId}</option>)}</select></label><label className="console-row"><strong></strong><input maxLength={200} value={v.notice} onChange={(event)=>edit({notice:event.target.value})}/></label></div><button className="primary-command" type="button" disabled={!selectedRevision||!v.selectedPlayer} onClick={()=>void requestGrant()}><Send size={14}/></button></div>{v.note&&<p className="page-status">{v.note}</p>}<div className="console-record-list">{v.grants.map((grant)=><div className="console-record" key={grant.id}><strong>{grantStatus(grant.status)}</strong><span>{grant.playerDisplayName} · {grant.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}</span><small> #{grant.revisionNumber} · {grant.deliverySummary||"等待投递结果"}{grant.notificationSummary?` · 通知:${grant.notificationSummary}`:""}</small>{grant.status==="pending-approval"&&<button className="icon-command" type="button" onClick={()=>void approve(grant)}><CheckCircle2 size={14}/><span></span></button>}</div>)}</div></section>;
}
function grantStatus(status: GameGiftGrantResponse["status"]) { return ({"pending-approval":"等待平台管理员审批",queued:"已排队投递",delivered:"物品已投递,通知处理中",notification_failed:"物品已投递,定向通知失败",failed:"投递失败",unknown:"投递结果未知,绝不自动重试"})[status]; }
@@ -1,14 +0,0 @@
import { describe, expect, it } from "vitest";
import source from "./GamePlayerIntelligencePanel.tsx?raw";
describe("GamePlayerIntelligencePanel", () => {
it("renders reviewable labels without raw network material or enforcement controls", () => {
expect(source).toContain("需要人工审核");
expect(source).toContain("失败尝试");
expect(source).toContain("受控技能与角色属性修改");
expect(source).toContain("等待平台管理员审批");
expect(source).toContain("写后读取确认失败");
expect(source).not.toContain("networkFingerprint");
expect(source).not.toMatch(/\bban\b|\bkick\b/i);
});
});
@@ -1,33 +0,0 @@
import { CheckCircle2, RefreshCw, ShieldAlert, SlidersHorizontal, UsersRound } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
import { GameGiftCatalogPanel } from "./GameGiftCatalogPanel";
type Selected = { profile: GamePlayerProfileResponse; state?: GamePlayerStateResponse; patches: GamePlayerStatePatchResponse[]; reason: string; after: Record<string, string>; note?: string };
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: Selected };
export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" });
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: message(error, "玩家档案读取失败。") }); } }, [serverInstanceId]);
useEffect(() => { void load(); }, [load]);
async function select(player: GamePlayerResponse) { try { const [profile, stateSnapshot, patchList] = await Promise.all([platformApiClient.getGamePlayerProfile(serverInstanceId, player.id), platformApiClient.getGamePlayerState(serverInstanceId, player.id), platformApiClient.listGamePlayerStatePatches(serverInstanceId, player.id)]); const after = Object.fromEntries(stateSnapshot.fields.map((field) => [field.key, String(field.value)])); setState((current) => current.status === "ready" ? { ...current, selected: { profile, state: stateSnapshot, patches: patchList.items, reason: "", after } } : current); } catch (error) { setState((current) => current.status === "ready" ? { ...current, selected: { profile: { player, aliases: [], sessions: [], accessAttempts: [], securitySignals: [] }, patches: [], reason: "", after: {}, note: message(error, "玩家状态快照不可用,已禁用修改。") } } : { status: "error", reason: message(error, "玩家档案读取失败。") }); } }
async function submitPatch() { if (state.status !== "ready") return; const selected = state.selected; if (!selected || !selected.state) return; const snapshot = selected.state; const changes = snapshot.fields.filter((field) => selected.after[field.key] !== String(field.value)).map((field) => ({ fieldKey: field.key, before: field.value, after: Number(selected.after[field.key]) })); try { const patch = await platformApiClient.requestGamePlayerStatePatch(serverInstanceId, selected.profile.player.id, { gameVersion: snapshot.gameVersion, expectedStateVersion: snapshot.stateVersion, safetyWindow: snapshot.safetyWindow || "", reason: selected.reason, changes }); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: [patch, ...current.selected.patches], note: "修改申请已保存,等待平台管理员审批。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "修改申请未被接受。") } } : current); } }
async function approvePatch(patch: GamePlayerStatePatchResponse) { if (state.status !== "ready" || !state.selected) return; try { const approved = await platformApiClient.approveGamePlayerStatePatch(serverInstanceId, state.selected.profile.player.id, patch.id); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: current.selected.patches.map((item) => item.id === approved.id ? approved : item), note: "已批准并通过受控游戏通道排队执行。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "审批失败。") } } : current); } }
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
const selected = state.selected;
return <><section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span> ID{player.gamePlayerId}</span><small>{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status"></p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section><GameGiftCatalogPanel serverInstanceId={serverInstanceId}/></>;
}
function PlayerDetail({ selected, onReason, onAfter, onSubmit, onApprove }: { selected: Selected; onReason: (value: string) => void; onAfter: (key: string, value: string) => void; onSubmit: () => void; onApprove: (patch: GamePlayerStatePatchResponse) => void }) {
const writable = Boolean(selected.state?.supported && selected.state.maintenanceVerified && !selected.state.playerOnline && selected.state.safetyWindow);
return <div className="console-module"><div className="panel-header"><h3>{selected.profile.player.displayName} </h3><span className="page-status"> {selected.profile.aliases.length} · {selected.profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong></strong><span>{selected.profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{selected.profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong></strong><span>{formatTime(session.startedAt)} {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{selected.profile.securitySignals.length ? selected.profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · {signal.evidenceCount} </small></div>) : <p className="page-status"></p>}</div>{selected.profile.accessAttempts.length > 0 && <p className="page-status">{selected.profile.accessAttempts.length} </p>}<div className="console-module"><div className="panel-header"><h3><SlidersHorizontal size={15} /> </h3><span className="page-status">{writable ? "已验证维护/离线安全窗口" : "未验证版本或安全窗口,已禁用修改"}</span></div>{selected.state && <><p className="provider-id">{selected.state.gameVersion} · {selected.state.stateVersion}</p><div className="console-row-list">{selected.state.fields.map((field) => <label className="console-row" key={field.key}><strong>{field.label}</strong><span> {field.value} {field.minimum}{field.maximum}</span><input aria-label={`${field.label} 修改后值`} type="number" min={field.minimum} max={field.maximum} step="0.1" disabled={!writable} value={selected.after[field.key] ?? ""} onChange={(event) => onAfter(field.key, event.target.value)} /></label>)}</div><label><textarea value={selected.reason} disabled={!writable} maxLength={240} onChange={(event) => onReason(event.target.value)} placeholder="说明需要修改的运营原因(4–240 字)" /></label><button type="button" className="command-button" disabled={!writable} onClick={onSubmit}></button></>}{selected.note && <p className="page-status">{selected.note}</p>}<div className="console-record-list">{selected.patches.map((patch) => <div className="console-record" key={patch.id}><strong>{patchStatus(patch.status)}</strong><span>{patch.changes.map((change) => `${change.fieldKey}: ${change.before}${change.after}`).join("")}</span><small>{patch.reason} · {patch.requesterId}{patch.approverId ? ` · 审批人:${patch.approverId}` : ""}{patch.executionSummary ? ` · 执行结果:${patch.executionSummary}` : ""}{patch.confirmedStateVersion ? ` · 确认状态版本:${patch.confirmedStateVersion}` : ""}</small>{patch.status === "pending-approval" && <button type="button" className="icon-command" onClick={() => onApprove(patch)}><CheckCircle2 size={14} /><span></span></button>}</div>)}</div></div></div>;
}
function setSelected(setState: React.Dispatch<React.SetStateAction<State>>, reason: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, reason } } : current); }
function setAfter(setState: React.Dispatch<React.SetStateAction<State>>, key: string, value: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, after: { ...current.selected.after, [key]: value } } } : current); }
function patchStatus(status: GamePlayerStatePatchResponse["status"]) { return ({ "pending-approval": "等待平台管理员审批", queued: "已批准,等待游戏侧执行", "execution-failed": "游戏侧执行失败", "execution-unknown": "游戏侧执行结果未知", "confirmation-failed": "写后读取确认失败", confirmed: "已确认生效" })[status]; }
function message(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
@@ -1,46 +0,0 @@
import { FileCode2, FileText, RefreshCw, Save, ScrollText } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { LogEntryBody, PluginFileWorkspaceResponse, ServerConfigDiffPreviewResponse, ServerConfigResponse } from "../api/types";
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
import { DiffView } from "./OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; config: ServerConfigResponse; values: Record<string, string>; unknown: Array<[string, string]> };
export function ScumFileConfigWorkbench({ contract, workspace }: { contract: ScumOperationsPageContract; workspace: PluginFileWorkspaceResponse }) {
const [state, setState] = useState<State>({ status: "loading" });
const [directoryKey, setDirectoryKey] = useState(workspace.defaultDirectoryKey);
const [selectedFileKey, setSelectedFileKey] = useState(workspace.files.find((file) => file.directoryKey === workspace.defaultDirectoryKey)?.key ?? "");
const [preview, setPreview] = useState<ServerConfigDiffPreviewResponse | null>(null);
const [result, setResult] = useState<string>("");
const [logEntries, setLogEntries] = useState<LogEntryBody[] | null>(null);
const files = useMemo(() => workspace.files.filter((file) => file.directoryKey === directoryKey), [directoryKey, workspace.files]);
const selectedFile = workspace.files.find((file) => file.key === selectedFileKey);
const fields = workspace.configFields.filter((field) => field.fileKey === selectedFileKey);
const load = useCallback(async () => {
setState({ status: "loading" }); setPreview(null); setResult("");
try { const config = await platformApiClient.getServerConfig(contract.serverInstanceId); const parsed = parseIni(config.content); const values = Object.fromEntries(workspace.configFields.map((field) => [field.key, parsed[field.configKey] ?? field.defaultValue ?? ""])); const known = new Set(workspace.configFields.map((field) => field.configKey)); setState({ status: "ready", config, values, unknown: Object.entries(parsed).filter(([key]) => !known.has(key)) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "无法读取平台配置投影。" }); }
}, [contract.serverInstanceId, workspace.configFields]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { setSelectedFileKey(workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? ""); setLogEntries(null); setPreview(null); }, [directoryKey, workspace.files]);
async function openLog() { if (!selectedFile?.streamKey) return; setLogEntries(null); try { const streams = await platformApiClient.listLogStreams(); const stream = streams.items.find((item) => item.serverInstanceId === contract.serverInstanceId && item.streamKey === selectedFile.streamKey); if (!stream) { setLogEntries([]); return; } const data = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 200), limit: 200 }); setLogEntries(data.entries); } catch (error) { setResult(error instanceof Error ? error.message : "日志文件内容读取失败。" ); setLogEntries([]); } }
async function previewChanges() { if (state.status !== "ready") return; const proposed = patchIni(state.config.content, fields, state.values); try { setPreview(await platformApiClient.previewServerConfigDiff(contract.serverInstanceId, { expectedConfigVersion: state.config.configVersion, expectedChecksum: state.config.checksum, key: state.config.key ?? selectedFileKey, proposedContent: proposed })); } catch (error) { setResult(error instanceof Error ? error.message : "配置差异预览失败。" ); } }
async function approve() { if (!preview) return; try { const response = await platformApiClient.approveServerConfigWrite(contract.serverInstanceId, { expectedConfigVersion: preview.configVersion, expectedChecksum: preview.checksum, key: preview.key, proposedContent: preview.proposedContent, idempotencyKey: `scum-files-config-${Date.now()}` }); setResult(`已派发配置写入任务 ${response.job.id}`); setPreview(null); await load(); } catch (error) { setResult(error instanceof Error ? error.message : "配置写入审批失败。" ); } }
if (state.status === "loading") return <LoadingState label="正在加载 SCUM 文件与配置工作台…" />;
if (state.status === "error") return <ErrorState title="SCUM 文件与配置不可用" reason={state.reason} onRetry={() => void load()} />;
return <div className="console-page" aria-label="SCUM 文件与配置工作台">
<section className="console-panel"><div className="panel-header"><div><h2><FileCode2 size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="action-strip">{workspace.directories.map((directory) => <button key={directory.key} type="button" className="icon-command" aria-pressed={directoryKey === directory.key} onClick={() => setDirectoryKey(directory.key)}><span>{directory.label}</span></button>)}</div><div className="console-record-list">{files.map((file) => <button key={file.key} type="button" className="console-record" aria-pressed={selectedFileKey === file.key} onClick={() => { setSelectedFileKey(file.key); setPreview(null); setLogEntries(null); }}><span>{file.kind === "log" ? <ScrollText size={15} /> : <FileText size={15} />}</span><strong>{file.label}</strong><small>{file.kind === "log" ? "日志文件" : file.editable ? "已建模配置" : "只读配置"}</small></button>)}</div></section>
{selectedFile?.kind === "config" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><span className="page-status">{fields.length} </span></div><div className="provider-form">{fields.map((field) => <label key={field.key}>{field.label}<small>{field.description} · {field.defaultValue || "--"} · {field.restartImpact === "restart-required" ? "修改后需重启" : "无需重启"} · {selectedFile.label}</small>{field.control === "boolean" ? <select value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)}><option value="true"></option><option value="false"></option></select> : <input type={field.control === "text" ? "text" : "number"} min={field.minimum} max={field.maximum} value={state.values[field.key]} onChange={(event) => setState((current) => current.status === "ready" ? { ...current, values: { ...current.values, [field.key]: event.target.value } } : current)} />}</label>)}</div><div className="console-module"><strong></strong>{state.unknown.length ? <div className="console-row-list">{state.unknown.map(([key, value]) => <div className="console-row" key={key}><span>{key}</span><code>{value}</code></div>)}</div> : <p className="page-status"></p>}</div><div className="action-strip"><button type="button" className="icon-command" onClick={() => void previewChanges()}><Save size={14} /><span></span></button>{preview && <button type="button" className="icon-command" onClick={() => void approve()}><span></span></button>}</div>{preview && <DiffView lines={preview.diff.map((line) => ({ kind: line.kind === "context" ? "same" : line.kind, text: line.content }))} />}</section>}
{selectedFile?.kind === "log" && <section className="console-panel"><div className="panel-header"><h2> · {selectedFile.label}</h2><button type="button" className="icon-command" onClick={() => void openLog()}><ScrollText size={14} /><span></span></button></div>{logEntries === null ? <EmptyState title="尚未读取日志文件" description="只读取该插件声明的日志流,不显示主机路径。" /> : logEntries.length === 0 ? <EmptyState title="暂无日志内容" description="当前声明的日志流没有可读取记录。" /> : <pre className="log-view">{logEntries.map((entry) => `${entry.timestamp} ${entry.line}`).join("\n")}</pre>}</section>}
{result && <ResultBadge status={result.startsWith("已派发") ? "succeeded" : "failed"} label={result} />}
</div>;
}
function parseIni(content: string): Record<string, string> { return Object.fromEntries(content.split(/\r?\n/).flatMap((line) => { const match = line.match(/^\s*([^=;#\s]+)\s*=\s*(.*?)\s*$/); return match ? [[match[1], match[2]]] : []; })); }
function patchIni(content: string, fields: PluginFileWorkspaceResponse["configFields"], values: Record<string, string>): string { let next = content; for (const field of fields) { const pattern = new RegExp(`(^\\s*${escapeRegExp(field.configKey)}\\s*=\\s*).*?$`, "m"); next = pattern.test(next) ? next.replace(pattern, `$1${values[field.key]}`) : `${next.replace(/\s*$/, "")}\n${field.configKey}=${values[field.key]}`; } return next; }
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { trajectoryPath } from "./ScumMapTrajectoryPanel";
describe("trajectoryPath", () => {
it("projects normalized map points into a stable SVG path", () => {
expect(trajectoryPath([{ mapX: 0, mapY: 0 }, { mapX: 500, mapY: 250 }, { mapX: 1000, mapY: 1000 }])).toBe("M0,1000 L500,750 L1000,0");
});
});
@@ -1,22 +0,0 @@
import { CarFront, MapPinned, RefreshCw, UserRound } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameMapTrajectoryEntityResponse, GameMapTrajectoryResponse } from "../api/types";
import { ErrorState, LoadingState } from "./StateViews";
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: GameMapTrajectoryResponse };
export function ScumMapTrajectoryPanel({ serverInstanceId }: { serverInstanceId: string }) {
const [state, setState] = useState<State>({ status: "loading" }); const [hours, setHours] = useState("1"); const [playerIds, setPlayerIds] = useState(""); const [vehicleIds, setVehicleIds] = useState(""); const [detail, setDetail] = useState("");
const load = useCallback(async () => { setState({ status: "loading" }); const to = new Date(); const from = new Date(to.valueOf() - Math.max(1, Math.min(24, Number(hours) || 1)) * 3600000); try { setState({ status: "ready", value: await platformApiClient.getGameMapTrajectories(serverInstanceId, { from: from.toISOString(), to: to.toISOString(), playerIds: ids(playerIds), vehicleIds: ids(vehicleIds) }) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "地图轨迹读取失败。" }); } }, [hours, playerIds, serverInstanceId, vehicleIds]);
useEffect(() => { void load(); }, [load]);
async function openPlayer(entity: GameMapTrajectoryEntityResponse) { if (!entity.gamePlayerRecordId) return; try { const profile = await platformApiClient.getGamePlayerProfile(serverInstanceId, entity.gamePlayerRecordId); setDetail(`玩家详情:${profile.player.displayName}${profile.player.gamePlayerId}),别名 ${profile.aliases.length} 条,会话 ${profile.sessions.length} 条。`); } catch (error) { setDetail(error instanceof Error ? error.message : "玩家详情不可用。"); } }
if (state.status === "loading") return <LoadingState label="正在读取 SCUM 安全地图投影…" />; if (state.status === "error") return <ErrorState title="地图轨迹不可用" reason={state.reason} onRetry={() => void load()} />;
const value = state.value; return <section className="console-panel scum-map-panel" aria-label="SCUM 玩家与车辆地图轨迹"><div className="panel-header"><div><h2><MapPinned size={16} /> SCUM </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="console-row-actions"><label><select value={hours} onChange={(event) => setHours(event.target.value)}><option value="1"> 1 </option><option value="6"> 6 </option><option value="24"> 24 </option></select></label><label> ID<input value={playerIds} onChange={(event) => setPlayerIds(event.target.value)} placeholder="逗号分隔" /></label><label> ID<input value={vehicleIds} onChange={(event) => setVehicleIds(event.target.value)} placeholder="逗号分隔" /></label><button type="button" className="command-button" onClick={() => void load()}></button></div>{value.status === "missing-map" ? <p className="page-status">{value.reason}</p> : <><p className="page-status"> {value.map?.mapId} · {value.map?.mapVersion} · {value.map?.precision} {formatTime(value.from)} {formatTime(value.to)}</p>{value.status === "empty" ? <p className="page-status">{value.reason}</p> : <TrajectoryMap players={value.players} vehicles={value.vehicles} onPlayer={openPlayer} onVehicle={(entity) => setDetail(`车辆详情上下文:${entity.entityId}。此链接只使用服务器内安全车辆标识,不查询原始存储。`)} />}{value.rideSegments.length > 0 && <div className="console-record-list">{value.rideSegments.map((segment) => <div className="console-record" key={`${segment.gamePlayerRecordId}-${segment.vehicleId}-${segment.startedAt}`}><strong></strong><span> {segment.gamePlayerRecordId} · {segment.vehicleId}</span><small>{formatTime(segment.startedAt)} {segment.endedAt ? formatTime(segment.endedAt) : "采集窗口内仍在车上"}</small></div>)}</div>}</>}{detail && <p className="page-status">{detail}</p>}</section>;
}
export function trajectoryPath(points: { mapX: number; mapY: number }[]) { return points.map((point, index) => `${index ? "L" : "M"}${point.mapX},${1000 - point.mapY}`).join(" "); }
function TrajectoryMap({ players, vehicles, onPlayer, onVehicle }: { players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; onPlayer: (entity: GameMapTrajectoryEntityResponse) => void; onVehicle: (entity: GameMapTrajectoryEntityResponse) => void }) { const entities = useMemo(() => [...players, ...vehicles], [players, vehicles]); return <><div className="scum-map-canvas" role="img" aria-label="已筛选的玩家和车辆地图轨迹"><svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet">{entities.map((entity) => <g key={`${entity.kind}-${entity.entityId}`}><path className={entity.kind === "player" ? "scum-map-player-line" : "scum-map-vehicle-line"} d={trajectoryPath(entity.points)} />{entity.points.map((point, index) => <circle key={`${point.occurredAt}-${index}`} className={entity.kind === "player" ? "scum-map-player-point" : "scum-map-vehicle-point"} cx={point.mapX} cy={1000 - point.mapY} r="7"><title>{`${entity.label} · ${formatTime(point.occurredAt)} · ${point.source}`}</title></circle>)}</g>)}</svg></div><div className="console-record-list">{entities.map((entity) => <div className="console-record" key={`${entity.kind}-${entity.entityId}`}><strong>{entity.kind === "player" ? <UserRound size={15} /> : <CarFront size={15} />} {entity.label}</strong><span>{entity.points.length} · {entity.sources.join(" / ") || "--"}</span><small>{formatTime(entity.collectedAt)}</small><button type="button" className="icon-command" onClick={() => entity.kind === "player" ? onPlayer(entity) : onVehicle(entity)}>{entity.kind === "player" ? <UserRound size={14} /> : <CarFront size={14} />}<span>{entity.kind === "player" ? "进入玩家详情" : "进入车辆上下文"}</span></button></div>)}</div></>; }
function ids(value: string) { return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 20); }
function formatTime(value: string | undefined) { if (!value) return "--"; const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
@@ -1,101 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { GameClientBridgeSnapshotResponse } from "../api/types";
import type { ScumOperationsPageContract } from "../contracts/scumOperations";
import { ScumOperationsPanel, type ScumOperationsPanelData } from "./ScumOperationsPanel";
import scumOperationsPanelSource from "./ScumOperationsPanel.tsx?raw";
const now = "2026-07-20T08:00:00Z";
const contract: ScumOperationsPageContract = {
pluginId: "game.scum",
routeKey: "files-config",
serverInstanceId: "server-1",
title: "SCUM 运维",
permissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"],
bridgeActions: ["server.instances.read", "logs.query", "client-manager.request"],
commands: [
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
{ type: "companion.diagnostics", title: "Collect diagnostics", permission: "server.game-client.read", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostics.json", timeoutSeconds: 30, maxPayloadBytes: 2048 },
{ type: "restart.prepare", title: "Prepare restart", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/restart.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
],
snapshots: ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"].map((type) => ({ type, schemaVersion: "1", schemaRef: `schemas/bridge/${type}.json`, keepForSeconds: 3600, maxRecords: 24 })),
queryTemplates: [],
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
logEvents: [
{ key: "scum-chat", title: "SCUM chat", sourceKey: "scum-chat-events", eventType: "scum.chat", permission: "server.logs.read", schemaRef: "schemas/log-events/chat.json", retentionDays: 30, severity: "info" },
{ key: "scum-kill", title: "SCUM kill", sourceKey: "scum-chat-events", eventType: "scum.kill", permission: "server.logs.read", schemaRef: "schemas/log-events/kill.json", retentionDays: 30, severity: "warning" }
],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
};
function snapshot(type: string, payload: GameClientBridgeSnapshotResponse["payload"]): GameClientBridgeSnapshotResponse {
return { id: `snapshot-${type}`, serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", type, schemaVersion: "1", streamKey: "current", sequence: 1, observedAt: now, payload, retention: { keepForSeconds: 3600, maxRecords: 24 }, createdAt: now, expiresAt: "2026-07-20T09:00:00Z" };
}
const data: ScumOperationsPanelData = {
status: { serverInstanceId: "server-1", pluginId: "game.scum", available: true, profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["announcement.send", "companion.diagnostics", "restart.prepare"], snapshotTypes: contract.snapshots.map((item) => item.type), queryTemplateKeys: [] }] },
commands: [{ id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", commandType: "announcement.send", priority: 50, state: "succeeded", approvalState: "approved", resultSummary: "announcement delivered", result: { status: "succeeded", summary: "announcement delivered", payload: { delivered: true }, completedAt: now }, expiresAt: now, createdAt: now, updatedAt: now, completedAt: now }],
snapshots: [
snapshot("companion.health", { status: "online", observedAt: now, version: "1.0.0", latencyMs: 20 }),
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight" }] }),
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online" }] }),
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4 }] }),
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked" }] }),
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active" }] })
],
logs: [{ streamKey: "scum.chat", eventType: "scum.chat", entry: { seq: 1, timestamp: now, level: "info", line: "token=raw-secret /Users/operator/scum.log", redacted: true } }],
backups: [{ id: "backup-1", serverInstanceId: "server-1", artifactId: "artifact-1", checksum: "sha256:safe", sizeBytes: 2048, state: "available", recoveryStatus: "verified", retentionUntil: "2026-07-27T08:00:00Z", createdAt: now, updatedAt: now }],
errors: []
};
describe("ScumOperationsPanel", () => {
it("renders the first safe SCUM operations surface", () => {
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={data} />);
for (const label of ["Companion", "命令队列与结果", "玩家与世界状态快照", "玩家 1", "会话 1", "小队 1", "载具 1", "旗帜 1", "语义日志", "scum.chat", "维护与备份策略", "backup-1", "已批准"]) {
expect(html).toContain(label);
}
expect(html).toContain("不触发自动封禁或惩罚");
expect(html).not.toContain("raw-secret");
expect(html).not.toContain("/Users/");
expect(html).not.toMatch(/sessionToken|componentKey|secretRef|hostPath|dsn|runSocket|credential/i);
});
it("keeps commands disabled with a visible bridge availability reason", () => {
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: false, reason: "compatible companion is offline", profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
expect(html).toContain("compatible companion is offline");
expect(html).toContain("disabled");
const profileReasonHtml = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, status: { ...data.status!, available: true, reason: undefined, profiles: data.status!.profiles.map((profile) => ({ ...profile, available: false, reason: "component heartbeat is unavailable" })) } }} />);
expect(profileReasonHtml).toContain("component heartbeat is unavailable");
expect(profileReasonHtml).toContain("disabled");
});
it("renders every command approval state without leaking unsafe result details", () => {
const approvalStates = ["not_required", "pending", "approved", "rejected"] as const;
const unsafeSummary = "token=raw-command-secret /Users/operator/result.json unix:///var/run/scum.sock";
const commands = approvalStates.map((approvalState, index) => ({
...data.commands[0]!,
id: `command-${index + 1}`,
approvalState,
resultSummary: approvalState === "rejected" ? unsafeSummary : `approval ${approvalState}`,
result: approvalState === "rejected" ? { ...data.commands[0]!.result!, summary: unsafeSummary } : data.commands[0]!.result
}));
const html = renderToStaticMarkup(<ScumOperationsPanel contract={contract} initialData={{ ...data, commands }} />);
for (const label of ["无需审批", "待审批", "已批准", "已拒绝"]) {
expect(html).toContain(label);
}
expect(html).not.toContain("raw-command-secret");
expect(html).not.toContain("/Users/operator");
expect(html).not.toContain("unix:///var/run");
});
it("uses shared console surfaces without page-local ambient decoration", () => {
expect(scumOperationsPanelSource).toContain('className="console-panel"');
expect(scumOperationsPanelSource).toContain('className="resource-table-wrap"');
expect(scumOperationsPanelSource).not.toMatch(/position:\s*fixed|sparkle|snowflake|magic-circle|backdrop-layer/i);
expect(scumOperationsPanelSource).not.toContain("InsecureSkipVerify");
});
});
@@ -1,436 +0,0 @@
import { Activity, BellRing, Database, RefreshCw, ShieldCheck, UsersRound, Wrench } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
BackupResponse,
GameClientBridgeCommandResponse,
GameClientBridgeJsonObject,
GameClientBridgeSnapshotResponse,
GameClientBridgeStatusResponse,
LogEntryBody,
LogStreamResponse
} from "../api/types";
import type { ScumOperationsPageContract, ScumOperationsSnapshotView } from "../contracts/scumOperations";
import { projectScumOperationsSnapshots } from "../schemas/scumOperations";
import { cx } from "../utils/classes";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type SnapshotSection = "players" | "sessions" | "squads" | "vehicles" | "flags";
export interface ScumSemanticLogView {
streamKey: string;
eventType: string;
entry: LogEntryBody;
}
export interface ScumOperationsPanelData {
status?: GameClientBridgeStatusResponse;
commands: GameClientBridgeCommandResponse[];
snapshots: GameClientBridgeSnapshotResponse[];
logs: ScumSemanticLogView[];
backups: BackupResponse[];
errors: string[];
}
type ScumOperationsPanelState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; data: ScumOperationsPanelData };
interface ScumOperationsPanelProps {
contract: ScumOperationsPageContract;
initialData?: ScumOperationsPanelData;
}
export function ScumOperationsPanel({ contract, initialData }: ScumOperationsPanelProps) {
const [state, setState] = useState<ScumOperationsPanelState>(() => initialData ? { status: "ready", data: initialData } : { status: "loading" });
const [snapshotSection, setSnapshotSection] = useState<SnapshotSection>("players");
const [announcement, setAnnouncement] = useState("");
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setState({ status: "loading" });
const results = await Promise.allSettled([
platformApiClient.getGameClientBridgeStatus(contract.serverInstanceId),
platformApiClient.listGameClientBridgeCommands(contract.serverInstanceId),
platformApiClient.listGameClientBridgeSnapshots(contract.serverInstanceId, { limit: 200 }),
loadSemanticLogs(contract),
platformApiClient.listBackups(contract.serverInstanceId)
]);
const errors = results.flatMap((result, index) => result.status === "rejected" ? [loadErrorLabel(index, result.reason)] : []);
const status = settledValue(results[0]);
const commands = settledValue(results[1]);
const snapshots = settledValue(results[2]);
const logs = settledValue(results[3]);
const backups = settledValue(results[4]);
if (!status && !commands && !snapshots) {
setState({ status: "error", reason: errors.join("") || "Game Client Bridge 核心投影不可用。" });
return;
}
setState({
status: "ready",
data: {
status,
commands: commands?.items ?? [],
snapshots: snapshots?.items ?? [],
logs: logs ?? [],
backups: backups?.items ?? [],
errors
}
});
}, [contract]);
useEffect(() => {
if (!initialData) {
void refresh();
}
}, [initialData, refresh]);
const snapshots = useMemo(() => state.status === "ready" ? projectScumOperationsSnapshots(state.data.snapshots) : emptySnapshotView(), [state]);
if (state.status === "loading") {
return <LoadingState label="正在加载 SCUM Bridge 运维投影…" />;
}
if (state.status === "error") {
return <ErrorState title="SCUM 运维投影不可用" reason={state.reason} onRetry={() => void refresh()} />;
}
const data = state.data;
const bridgeAvailable = data.status?.available === true;
const bridgeReason = data.status?.reason || data.status?.profiles.find((profile) => !profile.available)?.reason;
const profile = data.status?.profiles.find((candidate) => candidate.available) ?? data.status?.profiles[0];
const canQueue = bridgeAvailable && profile?.available === true;
const diagnostics = contract.commands.find((command) => command.type === "companion.diagnostics");
const announcementDeclaration = contract.commands.find((command) => command.type === "announcement.send");
const announcementAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("announcement.send")) === true;
const diagnosticsAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("companion.diagnostics")) === true;
const latestSnapshotAt = data.snapshots.reduce((latest, snapshot) => snapshot.observedAt > latest ? snapshot.observedAt : latest, "");
async function queueCommand(commandType: string, payload: GameClientBridgeJsonObject) {
const declaration = contract.commands.find((command) => command.type === commandType);
const commandProfile = data.status?.profiles.find((candidate) => candidate.available && candidate.commandTypes.includes(commandType));
if (!declaration || !commandProfile) {
setActionResult({ status: "failed", label: "命令未在当前可用 Bridge profile 中声明。" });
return;
}
setPendingCommand(commandType);
setActionResult({ status: "pending", label: `正在提交 ${declaration.title}` });
try {
const expirySeconds = Math.max(300, Math.min(3600, declaration.timeoutSeconds * 2));
const queued = await platformApiClient.queueGameClientBridgeCommand(contract.serverInstanceId, {
profileKey: commandProfile.profileKey,
commandType,
payload,
idempotencyKey: `web-scum-${commandType.replaceAll(".", "-")}-${Date.now()}`,
priority: 50,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
});
setState((current) => current.status === "ready" ? {
status: "ready",
data: { ...current.data, commands: [queued, ...current.data.commands.filter((command) => command.id !== queued.id)] }
} : current);
setAnnouncement("");
setActionResult({ status: "succeeded", label: `命令已进入队列,审批状态:${approvalLabel(queued.approvalState)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
} finally {
setPendingCommand(null);
}
}
function submitAnnouncement(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const message = announcement.trim();
if (!message || message.length > 500) {
setActionResult({ status: "failed", label: "公告内容必须为 1500 个字符。" });
return;
}
void queueCommand("announcement.send", { message });
}
return (
<div className="console-page" aria-label="SCUM operations">
<div className="panel-header">
<div>
<strong>Game Client Bridge </strong>
<p className="provider-id"> Platform DTO game.scum Run socket</p>
</div>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<RefreshCw size={14} aria-hidden="true" />
<span></span>
</button>
</div>
{data.errors.length > 0 && <ErrorState title="部分运维投影不可用" reason={data.errors.join("")} compact />}
<div className="console-grid" aria-label="SCUM operations summary">
<Metric label="Bridge" value={bridgeAvailable ? "可用" : "不可用"} detail={bridgeReason || profile?.profileKey || "未声明 profile"} tone={bridgeAvailable ? "success" : "warning"} />
<Metric label="Companion" value={healthLabel(snapshots.health?.status)} detail={snapshots.health?.observedAt ? `观测于 ${formatTime(snapshots.health.observedAt)}` : "暂无健康快照"} tone={snapshots.health?.status === "online" ? "success" : "warning"} />
<Metric label="在线会话" value={String(snapshots.sessions.total)} detail={snapshots.sessions.observedAt ? `快照 ${formatTime(snapshots.sessions.observedAt)}` : "暂无会话快照"} tone="neutral" />
<Metric label="最近快照" value={latestSnapshotAt ? formatTime(latestSnapshotAt) : "--"} detail={`${data.snapshots.length} 条安全投影`} tone="neutral" />
</div>
<section className="console-panel" aria-label="SCUM command queue">
<div className="panel-header">
<h2><BellRing size={16} aria-hidden="true" /> </h2>
<span className="page-status">{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}</span>
</div>
<div className="action-list">
{announcementDeclaration && (
<form className="provider-form" onSubmit={submitAnnouncement} aria-label="发送 SCUM 公告">
<div className="form-grid">
<label>
<input value={announcement} maxLength={500} onChange={(event) => setAnnouncement(event.target.value)} placeholder="输入 1500 字公告" disabled={!announcementAvailable || pendingCommand !== null} />
</label>
</div>
<button type="submit" className="icon-command" disabled={!announcementAvailable || pendingCommand !== null || !announcement.trim()}>
<BellRing size={14} aria-hidden="true" />
<span>{pendingCommand === "announcement.send" ? "提交中" : `提交公告 · ${approvalLevelLabel(announcementDeclaration.approvalLevel)}`}</span>
</button>
</form>
)}
{diagnostics && (
<div className="plugin-control-row">
<span><strong>{diagnostics.title}</strong><p> 10 </p></span>
<button type="button" className="icon-command" disabled={!diagnosticsAvailable || pendingCommand !== null} onClick={() => void queueCommand("companion.diagnostics", { includeWindowState: true, maxEntries: 10 })}>
<Activity size={14} aria-hidden="true" />
<span>{pendingCommand === "companion.diagnostics" ? "提交中" : "采集诊断"}</span>
</button>
</div>
)}
{actionResult && <ResultBadge status={actionResult.status} label={safeDiagnosticText(actionResult.label) ?? actionResult.label} />}
</div>
<CommandTable commands={data.commands} />
</section>
<section className="console-panel" aria-label="SCUM snapshot projections">
<div className="panel-header">
<h2><UsersRound size={16} aria-hidden="true" /> </h2>
<div className="action-strip" role="group" aria-label="SCUM snapshot sections">
{snapshotTabs(snapshots).map((tab) => (
<button key={tab.key} type="button" className={cx("icon-command")} aria-pressed={snapshotSection === tab.key} onClick={() => setSnapshotSection(tab.key)}>
<span>{tab.label} {tab.count}</span>
</button>
))}
</div>
</div>
<SnapshotTable section={snapshotSection} snapshots={snapshots} />
</section>
<section className="console-panel" aria-label="SCUM semantic logs">
<div className="panel-header">
<h2><Database size={16} aria-hidden="true" /> </h2>
<span className="page-status">{contract.logEvents.length} </span>
</div>
<div className="action-list">
<span>{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"}</span>
<span>线</span>
</div>
<SemanticLogTable logs={data.logs} />
</section>
<section className="console-panel" aria-label="SCUM maintenance policy">
<div className="panel-header">
<h2><Wrench size={16} aria-hidden="true" /> </h2>
<span className="page-status"></span>
</div>
<div className="action-list">
<span><strong></strong> {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"}</span>
<span><strong></strong> {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"}</span>
{contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => (
<span key={command.type}><ShieldCheck size={14} aria-hidden="true" /> {command.title} · {approvalLevelLabel(command.approvalLevel)}</span>
))}
<span>Platform </span>
</div>
<BackupTable backups={data.backups} />
</section>
</div>
);
}
async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise<ScumSemanticLogView[]> {
const streamsResponse = await platformApiClient.listLogStreams();
const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source]));
const eventTypesByStream = new Map<string, string[]>();
for (const event of contract.logEvents) {
const streamKey = sourceByKey.get(event.sourceKey)?.streamKey;
if (!streamKey) continue;
eventTypesByStream.set(streamKey, [...(eventTypesByStream.get(streamKey) ?? []), event.eventType]);
}
const streams = streamsResponse.items.filter((stream) => stream.serverInstanceId === contract.serverInstanceId && eventTypesByStream.has(stream.streamKey)).slice(0, 12);
const results = await Promise.allSettled(streams.map(async (stream) => ({
stream,
response: await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 50), limit: 50 })
})));
return results.flatMap((result) => {
if (result.status === "rejected") return [];
return result.value.response.entries.map((entry) => ({
streamKey: result.value.stream.streamKey,
eventType: declaredEventType(entry, result.value.stream, eventTypesByStream),
entry
}));
}).sort((left, right) => right.entry.timestamp.localeCompare(left.entry.timestamp)).slice(0, 100);
}
function declaredEventType(entry: LogEntryBody, stream: LogStreamResponse, eventTypesByStream: Map<string, string[]>): string {
const declared = eventTypesByStream.get(stream.streamKey) ?? [];
const projected = entry.fields?.eventType;
return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey;
}
function settledValue<T>(result: PromiseSettledResult<T>): T | undefined {
return result.status === "fulfilled" ? result.value : undefined;
}
function loadErrorLabel(index: number, reason: unknown): string {
const labels = ["Bridge 状态", "命令队列", "快照", "语义日志", "备份记录"];
const detail = safeDiagnosticText(reason instanceof Error ? reason.message : String(reason), "加载失败");
return `${labels[index] ?? "运维数据"}${detail}`;
}
function Metric({ label, value, detail, tone }: { label: string; value: string; detail: string; tone: "neutral" | "success" | "warning" }) {
return (
<article className={cx("metric-card", `metric-tone-${tone}`)}>
<span className="metric-label">{label}</span>
<strong className="metric-value">{value}</strong>
<p className="metric-detail">{detail}</p>
</article>
);
}
function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) {
if (commands.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{commands.slice(0, 50).map((command) => (
<tr key={command.id}>
<td><strong>{command.commandType}</strong><span className="provider-id">{command.id}</span></td>
<td>{commandStateLabel(command.state)}</td>
<td>{approvalLabel(command.approvalState)}</td>
<td>{safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"}</td>
<td>{formatTime(command.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function snapshotTabs(snapshots: ScumOperationsSnapshotView): Array<{ key: SnapshotSection; label: string; count: number }> {
return [
{ key: "players", label: "玩家", count: snapshots.players.total },
{ key: "sessions", label: "会话", count: snapshots.sessions.total },
{ key: "squads", label: "小队", count: snapshots.squads.total },
{ key: "vehicles", label: "载具", count: snapshots.vehicles.total },
{ key: "flags", label: "旗帜", count: snapshots.flags.total }
];
}
function SnapshotTable({ section, snapshots }: { section: SnapshotSection; snapshots: ScumOperationsSnapshotView }) {
const configuration: Record<SnapshotSection, { headers: string[]; rows: ReactNode[][]; total: number }> = {
players: { headers: ["玩家", "状态", "小队", "延迟", "最后出现"], total: snapshots.players.total, rows: snapshots.players.items.map((item) => [`${item.playerName} · ${item.playerId}`, item.status, item.squadId ?? "--", item.pingMs === undefined ? "--" : `${item.pingMs} ms`, formatTime(item.lastSeenAt)]) },
sessions: { headers: ["会话", "玩家", "开始时间"], total: snapshots.sessions.total, rows: snapshots.sessions.items.map((item) => [item.sessionId, item.playerName, formatTime(item.startedAt)]) },
squads: { headers: ["小队", "成员", "队长", "最后活跃"], total: snapshots.squads.total, rows: snapshots.squads.items.map((item) => [`${item.name} · ${item.squadId}`, item.memberCount, item.leaderPlayerId ?? "--", formatTime(item.lastActiveAt)]) },
vehicles: { headers: ["载具", "状态", "所有者 / 小队", "燃油 / 耐久", "最后出现"], total: snapshots.vehicles.total, rows: snapshots.vehicles.items.map((item) => [`${item.vehicleType} · ${item.vehicleId}`, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, `${percent(item.fuelPercent)} / ${percent(item.healthPercent)}`, formatTime(item.lastSeenAt)]) },
flags: { headers: ["旗帜", "状态", "所有者 / 小队", "半径", "更新时间"], total: snapshots.flags.total, rows: snapshots.flags.items.map((item) => [item.flagId, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, item.radiusMeters === undefined ? "--" : `${item.radiusMeters} m`, formatTime(item.lastUpdatedAt)]) }
};
const table = configuration[section];
if (table.total === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr>{table.headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{table.rows.map((row, rowIndex) => <tr key={`${section}-${rowIndex}`}>{row.map((cell, index) => <td key={`${index}-${String(cell)}`}>{cell}</td>)}</tr>)}</tbody>
</table>
{table.total > table.rows.length && <p className="provider-id"> {table.rows.length} / {table.total} </p>}
</div>
);
}
function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) {
if (logs.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{logs.slice(0, 100).map((log) => (
<tr key={`${log.streamKey}-${log.entry.seq}`}>
<td>{log.eventType}</td>
<td>{formatTime(log.entry.timestamp)}</td>
<td>{log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""}</td>
<td>{safeDiagnosticText(log.entry.line, "日志内容已隐藏")}</td>
</tr>
))}</tbody>
</table>
</div>
);
}
function BackupTable({ backups }: { backups: BackupResponse[] }) {
if (backups.length === 0) return <p className="provider-id"> Platform </p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{backups.slice(0, 20).map((backup) => (
<tr key={backup.id}><td>{backup.id}</td><td>{backup.state}</td><td>{formatBytes(backup.sizeBytes)}</td><td>{backup.recoveryStatus || "--"}</td><td>{formatTime(backup.retentionUntil)}</td></tr>
))}</tbody>
</table>
</div>
);
}
function emptySnapshotView(): ScumOperationsSnapshotView {
const empty = { total: 0, items: [] };
return { sessions: { ...empty }, players: { ...empty }, squads: { ...empty }, vehicles: { ...empty }, flags: { ...empty } };
}
function approvalLevelLabel(level: string): string {
if (level === "platform-admin") return "需平台管理员审批";
if (level === "operator") return "需操作员审批";
return "无需额外审批";
}
function approvalLabel(state: string): string {
if (state === "not_required") return "无需审批";
if (state === "approved") return "已批准";
if (state === "rejected") return "已拒绝";
return "待审批";
}
function commandStateLabel(state: string): string {
const labels: Record<string, string> = { pending: "等待领取", claimed: "执行中", succeeded: "成功", failed: "失败", cancelled: "已取消", expired: "已过期" };
return labels[state] ?? state;
}
function healthLabel(status: string | undefined): string {
if (status === "online") return "在线";
if (status === "degraded") return "降级";
if (status === "offline") return "离线";
return "未知";
}
function formatTime(value: string | undefined): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "--" : parsed.toLocaleString("zh-CN", { hour12: false });
}
function percent(value: number | undefined): string {
return value === undefined ? "--" : `${Math.round(value)}%`;
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { normalizeScumRouteKey, resolveScumOperationsPageContract } from "./scumOperations";
const plugin = {
id: "game.scum",
pages: [{
key: "files-config",
title: "文件与配置",
path: "/files-config",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access", "unknown.permission"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request", "unknown.action"]
}],
gameClientBridge: {
commands: [
{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 },
{ type: "maintenance.prepare", title: "Prepare maintenance", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", payloadSchemaRef: "schemas/bridge/maintenance.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }
],
snapshots: [
{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 },
{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.json", keepForSeconds: 3600, maxRecords: 24 }
],
queryTemplates: [
{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }
],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
runtimeProfiles: {
logSources: [{ key: "scum-chat-events", kind: "file.tail", streamKey: "scum.chat", retentionDays: 30 }],
logEvents: [{ key: "scum-chat", title: "SCUM chat", sourceKey: "scum-chat-events", eventType: "scum.chat", permission: "server.logs.read", schemaRef: "schemas/log-events/chat.json", retentionDays: 30, severity: "info" }]
},
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }
} satisfies Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle">;
describe("SCUM operations page contract", () => {
it("projects only declarations owned by the plugin operations page", () => {
const resolution = resolveScumOperationsPageContract(plugin, "server-1");
expect(resolution).toMatchObject({
available: true,
contract: {
pluginId: "game.scum",
routeKey: "files-config",
serverInstanceId: "server-1",
permissions: ["server.game-client.read", "server.game-client.command", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
commands: [{ type: "announcement.send" }],
snapshots: [{ type: "companion.health" }],
queryTemplates: [{ key: "scum.player.search", engine: "sqlite" }],
logEvents: [{ eventType: "scum.chat" }],
productionLifecycle: { dependencyPolicy: "required" }
}
});
expect(JSON.stringify(resolution)).not.toMatch(/sqlText|hostPath|sessionToken|componentKey|credential|socket/i);
});
it("reports declaration and server-context availability without inventing fallback semantics", () => {
expect(resolveScumOperationsPageContract({ ...plugin, id: "game.other" }, "server-1")).toMatchObject({ available: false });
expect(resolveScumOperationsPageContract(plugin, "")).toMatchObject({ available: false, reason: "缺少服务器实例上下文。" });
expect(resolveScumOperationsPageContract({ ...plugin, gameClientBridge: undefined }, "server-1")).toMatchObject({ available: false });
});
it("migrates only legacy SCUM page keys to the files-and-config workbench", () => {
expect(normalizeScumRouteKey("game.scum", "overview")).toBe("files-config");
expect(normalizeScumRouteKey("game.scum", "logs")).toBe("files-config");
expect(normalizeScumRouteKey("game.other", "logs")).toBe("logs");
});
});
-146
View File
@@ -1,146 +0,0 @@
import type {
GameClientBridgeCommandDeclarationResponse,
GameClientBridgeQueryTemplateDeclarationResponse,
GameClientBridgeSnapshotDeclarationResponse,
GamePluginResponse,
PluginProductionLifecycleDeclaration,
RuntimeLogEventResponse,
RuntimeLogSourceResponse
} from "../api/types";
import {
isPluginBridgeAction,
isPluginPermission,
type PluginBridgeAction,
type PluginPermission
} from "./pluginBridge";
export const scumOperationsPluginId = "game.scum";
export const scumOperationsRouteKey = "files-config";
export interface ScumOperationsPageContract {
pluginId: typeof scumOperationsPluginId;
routeKey: typeof scumOperationsRouteKey;
serverInstanceId: string;
title: string;
permissions: PluginPermission[];
bridgeActions: PluginBridgeAction[];
commands: GameClientBridgeCommandDeclarationResponse[];
snapshots: GameClientBridgeSnapshotDeclarationResponse[];
queryTemplates: GameClientBridgeQueryTemplateDeclarationResponse[];
logSources: RuntimeLogSourceResponse[];
logEvents: RuntimeLogEventResponse[];
productionLifecycle: PluginProductionLifecycleDeclaration;
}
export interface ScumCompanionHealthView {
status: "online" | "degraded" | "offline" | "unknown";
version?: string;
observedAt?: string;
latencyMs?: number;
capabilities: string[];
}
export interface ScumSessionView {
sessionId: string;
playerName: string;
startedAt?: string;
}
export interface ScumPlayerView {
playerId: string;
playerName: string;
status: string;
squadId?: string;
pingMs?: number;
lastSeenAt?: string;
}
export interface ScumSquadView {
squadId: string;
name: string;
memberCount: number;
leaderPlayerId?: string;
lastActiveAt?: string;
}
export interface ScumVehicleView {
vehicleId: string;
vehicleType: string;
status: string;
ownerPlayerId?: string;
squadId?: string;
fuelPercent?: number;
healthPercent?: number;
lastSeenAt?: string;
}
export interface ScumFlagView {
flagId: string;
status: string;
ownerPlayerId?: string;
squadId?: string;
radiusMeters?: number;
lastUpdatedAt?: string;
}
export interface ScumSnapshotCollection<T> {
observedAt?: string;
total: number;
items: T[];
}
export interface ScumOperationsSnapshotView {
health?: ScumCompanionHealthView;
sessions: ScumSnapshotCollection<ScumSessionView>;
players: ScumSnapshotCollection<ScumPlayerView>;
squads: ScumSnapshotCollection<ScumSquadView>;
vehicles: ScumSnapshotCollection<ScumVehicleView>;
flags: ScumSnapshotCollection<ScumFlagView>;
}
export type ScumOperationsPageResolution =
| { available: true; contract: ScumOperationsPageContract }
| { available: false; reason: string };
type ScumPluginProjection = Pick<GamePluginResponse, "id" | "pages" | "gameClientBridge" | "runtimeProfiles" | "productionLifecycle" | "fileWorkspace">;
export function normalizeScumRouteKey(pluginId: string, routeKey: string): string { return pluginId === scumOperationsPluginId && ["overview", "operations", "config", "logs"].includes(routeKey) ? scumOperationsRouteKey : routeKey; }
export function resolveScumOperationsPageContract(plugin: ScumPluginProjection, serverInstanceId: string): ScumOperationsPageResolution {
if (plugin.id !== scumOperationsPluginId) {
return { available: false, reason: "该路由仅承载 game.scum 插件声明的运维页。" };
}
if (!serverInstanceId.trim()) {
return { available: false, reason: "缺少服务器实例上下文。" };
}
const page = plugin.pages.find((candidate) => candidate.key === scumOperationsRouteKey);
if (!page) {
return { available: false, reason: "SCUM 插件未声明 operations 页面。" };
}
const manifest = plugin.gameClientBridge;
const bridgePage = manifest?.pages?.find((candidate) => candidate.pageKey === scumOperationsRouteKey);
if (!manifest || !bridgePage) {
return { available: false, reason: "SCUM 插件未声明 operations 的 Game Client Bridge 契约。" };
}
const commandTypes = new Set(bridgePage.commandTypes ?? []);
const snapshotTypes = new Set(bridgePage.snapshotTypes ?? []);
const queryTemplateKeys = new Set(bridgePage.queryTemplateKeys ?? []);
return {
available: true,
contract: {
pluginId: scumOperationsPluginId,
routeKey: scumOperationsRouteKey,
serverInstanceId,
title: page.title,
permissions: page.permissions.filter(isPluginPermission),
bridgeActions: (page.bridgeActions ?? []).filter(isPluginBridgeAction),
commands: manifest.commands.filter((command) => commandTypes.has(command.type)),
snapshots: manifest.snapshots.filter((snapshot) => snapshotTypes.has(snapshot.type)),
queryTemplates: (manifest.queryTemplates ?? []).filter((template) => queryTemplateKeys.has(template.key)),
logSources: [...(plugin.runtimeProfiles?.logSources ?? [])],
logEvents: [...(plugin.runtimeProfiles?.logEvents ?? [])],
productionLifecycle: plugin.productionLifecycle
}
};
}
@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
import { projectScumOperationsSnapshots } from "./scumOperations";
const now = "2026-07-20T08:00:00Z";
function snapshot(type: string, payload: GameClientBridgeJsonObject, sequence = 1): GameClientBridgeSnapshotResponse {
return {
id: `${type}-${sequence}`,
serverInstanceId: "server-1",
pluginId: "game.scum",
profileKey: "scum-client",
type,
schemaVersion: "1",
streamKey: "current",
sequence,
observedAt: now,
payload,
retention: { keepForSeconds: 3600, maxRecords: 24 },
createdAt: now,
expiresAt: "2026-07-20T09:00:00Z"
};
}
describe("SCUM operations snapshot projection", () => {
it("projects companion, player, session, squad, vehicle and flag snapshots", () => {
const view = projectScumOperationsSnapshots([
snapshot("companion.health", { status: "online", version: "1.2.0", observedAt: now, latencyMs: 24, capabilities: ["game-client.bridge"] }),
snapshot("online.sessions", { observedAt: now, onlineCount: 1, sessions: [{ sessionId: "game-session-1", playerName: "Moonlight", startedAt: now }] }),
snapshot("players", { observedAt: now, players: [{ playerId: "player-1", playerName: "Moonlight", status: "online", squadId: "squad-1", pingMs: 33 }] }),
snapshot("squads", { observedAt: now, squads: [{ squadId: "squad-1", name: "Lunar", memberCount: 4, leaderPlayerId: "player-1" }] }),
snapshot("vehicles", { observedAt: now, vehicles: [{ vehicleId: "vehicle-1", vehicleType: "truck", status: "parked", ownerPlayerId: "player-1", fuelPercent: 70, healthPercent: 80 }] }),
snapshot("flags", { observedAt: now, flags: [{ flagId: "flag-1", status: "active", squadId: "squad-1", radiusMeters: 25 }] })
]);
expect(view).toMatchObject({
health: { status: "online", version: "1.2.0", latencyMs: 24 },
sessions: { total: 1, items: [{ sessionId: "game-session-1" }] },
players: { total: 1, items: [{ playerId: "player-1", squadId: "squad-1" }] },
squads: { total: 1, items: [{ memberCount: 4 }] },
vehicles: { total: 1, items: [{ fuelPercent: 70 }] },
flags: { total: 1, items: [{ radiusMeters: 25 }] }
});
});
it("uses the newest sequence and redacts sensitive-looking display strings", () => {
const view = projectScumOperationsSnapshots([
snapshot("players", { observedAt: now, players: [{ playerId: "player-old", playerName: "Old", status: "offline" }] }, 1),
{ ...snapshot("players", { observedAt: now, players: [{ playerId: "player-new", playerName: "token=raw-secret /Users/operator/file", status: "online" }] }, 2), observedAt: now }
]);
expect(view.players.items[0]).toMatchObject({ playerId: "player-new", status: "online" });
expect(view.players.items[0]?.playerName).not.toContain("raw-secret");
expect(view.players.items[0]?.playerName).not.toContain("/Users/");
});
});
-148
View File
@@ -1,148 +0,0 @@
import type { GameClientBridgeJsonObject, GameClientBridgeSnapshotResponse } from "../api/types";
import type {
ScumCompanionHealthView,
ScumFlagView,
ScumOperationsSnapshotView,
ScumPlayerView,
ScumSessionView,
ScumSnapshotCollection,
ScumSquadView,
ScumVehicleView
} from "../contracts/scumOperations";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
const maxVisibleItems = 50;
export function projectScumOperationsSnapshots(snapshots: GameClientBridgeSnapshotResponse[]): ScumOperationsSnapshotView {
return {
health: projectHealth(latestPayload(snapshots, "companion.health")),
sessions: projectCollection(latestSnapshot(snapshots, "online.sessions"), "sessions", projectSession),
players: projectCollection(latestSnapshot(snapshots, "players"), "players", projectPlayer),
squads: projectCollection(latestSnapshot(snapshots, "squads"), "squads", projectSquad),
vehicles: projectCollection(latestSnapshot(snapshots, "vehicles"), "vehicles", projectVehicle),
flags: projectCollection(latestSnapshot(snapshots, "flags"), "flags", projectFlag)
};
}
function latestSnapshot(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeSnapshotResponse | undefined {
return snapshots
.filter((snapshot) => snapshot.type === type)
.sort((left, right) => right.observedAt.localeCompare(left.observedAt) || right.sequence - left.sequence)[0];
}
function latestPayload(snapshots: GameClientBridgeSnapshotResponse[], type: string): GameClientBridgeJsonObject | undefined {
return latestSnapshot(snapshots, type)?.payload;
}
function projectHealth(payload: GameClientBridgeJsonObject | undefined): ScumCompanionHealthView | undefined {
if (!payload) return undefined;
const status = readText(payload.status, 20);
return {
status: status === "online" || status === "degraded" || status === "offline" ? status : "unknown",
version: readOptionalText(payload.version, 40),
observedAt: readOptionalText(payload.observedAt, 64),
latencyMs: readOptionalNumber(payload.latencyMs, 0, 30000),
capabilities: readArray(payload.capabilities).map((value) => readText(value, 80)).filter(Boolean).slice(0, 16)
};
}
function projectCollection<T>(
snapshot: GameClientBridgeSnapshotResponse | undefined,
key: string,
project: (value: unknown) => T | null
): ScumSnapshotCollection<T> {
const values = snapshot ? readArray(snapshot.payload[key]) : [];
return {
observedAt: snapshot?.observedAt,
total: values.length,
items: values.slice(0, maxVisibleItems).map(project).filter((value): value is T => value !== null)
};
}
function projectSession(value: unknown): ScumSessionView | null {
const record = readObject(value);
const sessionId = readText(record?.sessionId, 120);
const playerName = readText(record?.playerName, 80);
return sessionId && playerName ? { sessionId, playerName, startedAt: readOptionalText(record?.startedAt, 64) } : null;
}
function projectPlayer(value: unknown): ScumPlayerView | null {
const record = readObject(value);
const playerId = readText(record?.playerId, 96);
const playerName = readText(record?.playerName, 80);
const status = readText(record?.status, 20);
return playerId && playerName && status ? {
playerId,
playerName,
status,
squadId: readOptionalText(record?.squadId, 96),
pingMs: readOptionalNumber(record?.pingMs, 0, 10000),
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
} : null;
}
function projectSquad(value: unknown): ScumSquadView | null {
const record = readObject(value);
const squadId = readText(record?.squadId, 96);
const name = readText(record?.name, 80);
const memberCount = readOptionalNumber(record?.memberCount, 0, 64);
return squadId && name && memberCount !== undefined ? {
squadId,
name,
memberCount,
leaderPlayerId: readOptionalText(record?.leaderPlayerId, 96),
lastActiveAt: readOptionalText(record?.lastActiveAt, 64)
} : null;
}
function projectVehicle(value: unknown): ScumVehicleView | null {
const record = readObject(value);
const vehicleId = readText(record?.vehicleId, 96);
const vehicleType = readText(record?.vehicleType, 80);
const status = readText(record?.status, 20);
return vehicleId && vehicleType && status ? {
vehicleId,
vehicleType,
status,
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
squadId: readOptionalText(record?.squadId, 96),
fuelPercent: readOptionalNumber(record?.fuelPercent, 0, 100),
healthPercent: readOptionalNumber(record?.healthPercent, 0, 100),
lastSeenAt: readOptionalText(record?.lastSeenAt, 64)
} : null;
}
function projectFlag(value: unknown): ScumFlagView | null {
const record = readObject(value);
const flagId = readText(record?.flagId, 96);
const status = readText(record?.status, 20);
return flagId && status ? {
flagId,
status,
ownerPlayerId: readOptionalText(record?.ownerPlayerId, 96),
squadId: readOptionalText(record?.squadId, 96),
radiusMeters: readOptionalNumber(record?.radiusMeters, 0, 5000),
lastUpdatedAt: readOptionalText(record?.lastUpdatedAt, 64)
} : null;
}
function readObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readText(value: unknown, maxLength: number): string {
if (typeof value !== "string") return "";
return (safeDiagnosticText(value.slice(0, maxLength), "") ?? "").trim();
}
function readOptionalText(value: unknown, maxLength: number): string | undefined {
return readText(value, maxLength) || undefined;
}
function readOptionalNumber(value: unknown, minimum: number, maximum: number): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum ? value : undefined;
}
-1
View File
@@ -755,7 +755,6 @@ button.operations-inline-warning{cursor:pointer}
.operations-tray-item-copy code{color:var(--gold);font-size:10px}
.operations-tray-item time{color:var(--ink-faint);font-size:10px;white-space:nowrap}
.operations-tray-empty{margin:10px 0 2px;color:var(--ink-faint);font-size:11px}
.scum-map-panel .console-row-actions{align-items:end}.scum-map-panel label{display:grid;gap:4px;min-width:150px;color:var(--ink-soft);font-size:12px}.scum-map-panel input,.scum-map-panel select{max-width:220px}.scum-map-canvas{margin:12px 0;border:1px solid var(--line);background:var(--frosted-surface),linear-gradient(90deg,color-mix(in srgb,var(--line) 36%,transparent) 1px,transparent 1px),linear-gradient(180deg,color-mix(in srgb,var(--line) 28%,transparent) 1px,transparent 1px);background-size:auto,10% 10%,10% 10%;box-shadow:var(--jelly-inset);aspect-ratio:16/9;overflow:hidden}.scum-map-canvas svg{display:block;width:100%;height:100%}.scum-map-player-line,.scum-map-vehicle-line{fill:none;stroke-width:5;stroke-linecap:round;stroke-linejoin:round}.scum-map-player-line{stroke:var(--accent)}.scum-map-vehicle-line{stroke:var(--gold)}.scum-map-player-point{fill:var(--accent-deep);stroke:var(--accent);stroke-width:3}.scum-map-vehicle-point{fill:var(--gold);stroke:var(--pink);stroke-width:3}
.app-shell-sidebar-collapsed .operations-tray{width:44px}
.app-shell-sidebar-collapsed .operations-tray-trigger{grid-template-columns:1fr;place-items:center;padding:6px 0}
.app-shell-sidebar-collapsed .operations-tray-copy,.app-shell-sidebar-collapsed .operations-tray-trigger>svg{display:none}