Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the production router rejected that path before the signature middleware because runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM rows inside the whole metadata snapshot instead of platform tables. - /api/v1/run/ is now the signed machine channel space while /api/v1/run/endpoints keeps normal bearer/admin authorization. - MySQL gets real scum_user, scum_user_trajectory, scum_vehicle, scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized per-row repositories instead of full snapshot rewrites. Snapshot-shaped tables from the unreleased interim build are replaced, and SCUM rows still inside a metadata snapshot are migrated once. - Facts ingest verifies the target server plugin type and converges stale online users to offline after SCUMUserOfflineAfter. - The plugin page and browser read one bounded /scum/surface response instead of five list calls per refresh. Call-count budget for one server: per 5s facts batch, one SELECT plus one INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory sample or new lock row, one bounded stale-user SELECT, and a trajectory retention DELETE at most once per hour. One browser refresh issues one surface request every 15s instead of five list requests.
179 lines
10 KiB
TypeScript
179 lines
10 KiB
TypeScript
import { ArrowLeft, PlugZap } from "lucide-react";
|
|
import * as React from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
|
|
|
import { platformApiClient } from "../api/client";
|
|
import type { GamePluginResponse } from "../api/types";
|
|
import { PageFrame } from "../components/PageFrame";
|
|
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
|
import type { PageComponentProps } from "../contracts/page";
|
|
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
|
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
|
|
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
|
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
|
|
|
|
type PluginPageState =
|
|
| { status: "loading" }
|
|
| { status: "error"; reason: string }
|
|
| { status: "ready"; plugin: GamePluginResponse };
|
|
|
|
interface PluginPageHostPageProps extends PageComponentProps {
|
|
initialPlugin?: GamePluginResponse;
|
|
embedded?: boolean;
|
|
}
|
|
|
|
export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded = false }: PluginPageHostPageProps) {
|
|
const pluginId = params.pluginId ?? "";
|
|
const routeKey = params.routeKey ?? "";
|
|
const serverId = params.serverId ?? "";
|
|
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
|
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
|
const [bundleError, setBundleError] = useState("");
|
|
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证插件桥接可用性。" });
|
|
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
|
|
const hostContextRef = useRef<ReturnType<typeof createPluginBridgeHostContext> | undefined>(undefined);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!pluginId || !routeKey) {
|
|
setState({ status: "error", reason: "插件页面路由缺少 pluginId 或 routeKey。" });
|
|
return;
|
|
}
|
|
setState({ status: "loading" });
|
|
try {
|
|
const response = await platformApiClient.listGamePlugins();
|
|
const plugin = response.items.find((candidate) => candidate.id === pluginId);
|
|
setState(plugin ? { status: "ready", plugin } : { status: "error", reason: "未找到已注册的插件声明。" });
|
|
} catch (error) {
|
|
setState({ status: "error", reason: error instanceof Error ? error.message : "插件页面声明加载失败。" });
|
|
}
|
|
}, [pluginId, routeKey]);
|
|
|
|
useEffect(() => {
|
|
if (!initialPlugin) {
|
|
void load();
|
|
}
|
|
}, [initialPlugin, load]);
|
|
|
|
const readyPlugin = state.status === "ready" ? state.plugin : undefined;
|
|
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
|
|
const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]);
|
|
const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey);
|
|
const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({
|
|
plugin: manifestContract,
|
|
routeKey,
|
|
serverInstanceId: serverId || undefined,
|
|
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
|
|
}) : undefined, [manifestContract, routeKey, serverId]);
|
|
readyPluginRef.current = readyPlugin;
|
|
hostContextRef.current = hostContext;
|
|
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
|
if (!pluginId) return undefined;
|
|
return {
|
|
pluginData: {
|
|
list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key),
|
|
put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value),
|
|
delete: (collection, key) => platformApiClient.deletePluginData(serverId, collection, key),
|
|
transact: (collection, mutations) => platformApiClient.transactPluginData(serverId, collection, mutations)
|
|
},
|
|
gameClient: {
|
|
queue: (request) => platformApiClient.queueGameClientBridgeCommand(serverId, request),
|
|
get: (commandId) => platformApiClient.getGameClientBridgeCommand(serverId, commandId),
|
|
list: (filter) => platformApiClient.listGameClientBridgeCommands(serverId, filter),
|
|
snapshots: (query) => platformApiClient.listGameClientBridgeSnapshots(serverId, query)
|
|
},
|
|
logs: {
|
|
listStreams: () => {
|
|
const context = hostContextRef.current;
|
|
if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read");
|
|
return platformApiClient.listLogStreams(serverId);
|
|
},
|
|
query: (request) => {
|
|
const context = hostContextRef.current;
|
|
if (!serverId || !context?.permissions.includes("server.logs.read")) throw new Error("plugin page is missing server.logs.read");
|
|
return platformApiClient.queryLogStream(request);
|
|
}
|
|
},
|
|
scum: {
|
|
surface: () => platformApiClient.getScumSurface(serverId),
|
|
users: (request) => platformApiClient.listScumUsers(serverId, request),
|
|
userTrajectories: (request) => platformApiClient.listScumUserTrajectories(serverId, request),
|
|
vehicles: (request) => platformApiClient.listScumVehicles(serverId, request),
|
|
vehicleTrajectories: (request) => platformApiClient.listScumVehicleTrajectories(serverId, request),
|
|
vehicleLocks: (request) => platformApiClient.listScumVehicleLocks(serverId, request)
|
|
},
|
|
dispatch: (envelope, signal) => {
|
|
const context = hostContextRef.current;
|
|
if (!context) return Promise.resolve({ requestId: envelope.requestId, action: envelope.action, status: "error", error: { code: "host_unavailable", message: "plugin bridge host is unavailable" } });
|
|
return createPluginBridgeDispatcher(context, platformApiClient)(envelope, signal);
|
|
}
|
|
};
|
|
}, [pluginId, serverId]);
|
|
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
|
const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]);
|
|
useEffect(() => {
|
|
let active = true;
|
|
if (!loadableBundlePage) return () => { active = false; };
|
|
globalThis.__PLUGIN_PAGE_REACT__ = React;
|
|
setBundle(null); setBundleError("");
|
|
void loadPluginPageBundle(loadableBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
|
|
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
|
|
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
|
|
if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features });
|
|
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证插件桥接可用性。" }); });
|
|
return () => { active = false; };
|
|
}, [bundleLoadKey, loadableBundlePage, serverId]);
|
|
|
|
if (state.status === "loading") {
|
|
return <LoadingState label="正在加载插件页面声明…" />;
|
|
}
|
|
if (state.status === "error") {
|
|
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
|
|
}
|
|
|
|
const page = declaredPage;
|
|
if (!page) {
|
|
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
|
|
}
|
|
if (!hostContext) {
|
|
return <ErrorState title="插件页面不可用" reason="插件页面上下文初始化失败。" />;
|
|
}
|
|
if (embedded) {
|
|
return (
|
|
<>
|
|
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
|
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
|
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<div className="console-page">
|
|
<PageFrame
|
|
kicker={`${state.plugin.name} / PLUGIN PAGE`}
|
|
title={page.title}
|
|
status={serverId ? `服务器 ${serverId}` : "未绑定服务器"}
|
|
metrics={[
|
|
{ label: "有效权限", value: String(hostContext.permissions.length), tone: hostContext.permissions.length > 0 ? "success" : "warning" },
|
|
{ label: "桥接动作", value: String(hostContext.bridgeActions.length), tone: hostContext.bridgeActions.length > 0 ? "success" : "warning" },
|
|
{ label: "页面路由", value: routeKey, tone: "neutral" }
|
|
]}
|
|
/>
|
|
|
|
<section className="console-panel" aria-label="plugin page host context">
|
|
<div className="panel-header">
|
|
<h2><PlugZap size={16} aria-hidden="true" /> 平台托管上下文</h2>
|
|
<button type="button" className="icon-command" onClick={() => onNavigate(serverId ? "serverDetail" : "plugins", serverId ? { serverId } : {})}>
|
|
<ArrowLeft size={14} aria-hidden="true" />
|
|
<span>{serverId ? "返回服务器" : "返回插件市场"}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>插件桥接</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
|
</section>
|
|
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
|
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
|
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
|
</div>
|
|
);
|
|
}
|