refactor(scum): remove orphaned host panels
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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: "公告内容必须为 1–500 个字符。" });
|
||||
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="输入 1–500 字公告" 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");
|
||||
});
|
||||
});
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user