- Open 「用户轨迹」 straight on the live map instead of a drawer, carrying the server, the focused user and a start/end window through the plugin page hash. - Default that window to one hour before the user's last seen time and keep the plugin page query available to bundles through the host context. - Replace the time range preset gate with always visible start/end datetime fields plus a quick-range select, so the user list rebuilds from the window. - Drop the bulky trajectory list and draw one compact colour legend under the map, keeping a stable colour per identity and restricting vehicle tracks to the vehicles the selected users actually rode.
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
import type {
|
|
PluginBridgeAction,
|
|
PluginBridgeExecuteEnvelope,
|
|
PluginBridgeExecutionResult,
|
|
PluginBridgeHostContext,
|
|
PluginBridgeManifestContract,
|
|
PluginBridgeThemeTokens,
|
|
PluginPermission
|
|
} from "../contracts/pluginBridge";
|
|
import type { PlatformApiClient } from "../api/client";
|
|
|
|
export function createPluginBridgeHostContext(input: {
|
|
plugin: PluginBridgeManifestContract;
|
|
routeKey: string;
|
|
serverInstanceId?: string;
|
|
pageQuery?: Record<string, string>;
|
|
themeTokens: PluginBridgeThemeTokens;
|
|
}): PluginBridgeHostContext {
|
|
const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey);
|
|
const permissions = filterAllowedPermissions(input.plugin.declaredPermissions, page?.permissions);
|
|
const bridgeActions =
|
|
page?.bridgeActions === undefined
|
|
? [...input.plugin.bridgeActions]
|
|
: page.bridgeActions.filter((action) => input.plugin.bridgeActions.includes(action));
|
|
|
|
return {
|
|
pluginId: input.plugin.id,
|
|
routeKey: input.routeKey,
|
|
serverInstanceId: input.serverInstanceId,
|
|
pageQuery: { ...(input.pageQuery ?? {}) },
|
|
themeTokens: input.themeTokens,
|
|
permissions,
|
|
bridgeActions,
|
|
aiPurposes: [...(input.plugin.aiPurposes ?? [])]
|
|
};
|
|
}
|
|
|
|
export function filterAllowedPermissions(
|
|
declaredPermissions: PluginPermission[],
|
|
pagePermissions: PluginPermission[] | undefined
|
|
): PluginPermission[] {
|
|
if (pagePermissions === undefined) {
|
|
return [...declaredPermissions];
|
|
}
|
|
return pagePermissions.filter((permission) => declaredPermissions.includes(permission));
|
|
}
|
|
|
|
export function createPluginBridgeDispatcher(context: PluginBridgeHostContext, client: Pick<PlatformApiClient, "executePluginBridge">) {
|
|
return async function dispatchBridgeRequest(envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal): Promise<PluginBridgeExecutionResult> {
|
|
const localError = validateBridgeExecutionRequest(context, envelope);
|
|
if (localError) {
|
|
return {
|
|
requestId: envelope.requestId,
|
|
action: envelope.action,
|
|
status: "denied",
|
|
error: localError
|
|
};
|
|
}
|
|
if (signal?.aborted) {
|
|
return {
|
|
requestId: envelope.requestId,
|
|
action: envelope.action,
|
|
status: "cancelled",
|
|
error: { code: "cancelled", message: "bridge request was cancelled" }
|
|
};
|
|
}
|
|
try {
|
|
const response = await client.executePluginBridge({
|
|
requestId: envelope.requestId,
|
|
pluginId: context.pluginId,
|
|
routeKey: context.routeKey,
|
|
serverInstanceId: context.serverInstanceId,
|
|
action: envelope.action,
|
|
aiPurpose: envelope.aiPurpose,
|
|
payload: envelope.payload
|
|
});
|
|
return {
|
|
requestId: response.requestId,
|
|
action: response.action as PluginBridgeAction,
|
|
status: response.status,
|
|
result: response.result,
|
|
error: response.error
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
requestId: envelope.requestId,
|
|
action: envelope.action,
|
|
status: "error",
|
|
error: { code: "platform_error", message: error instanceof Error ? error.message : "bridge execution failed" }
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
export function validateBridgeExecutionRequest(context: PluginBridgeHostContext, envelope: PluginBridgeExecuteEnvelope): PluginBridgeExecutionResult["error"] | null {
|
|
if (!envelope.requestId.trim()) {
|
|
return { code: "validation", message: "requestId is required" };
|
|
}
|
|
if (!context.bridgeActions.includes(envelope.action)) {
|
|
return { code: "unsupported_action", message: "bridge action is not allowed for this page" };
|
|
}
|
|
for (const permission of requiredPermissions(envelope.action)) {
|
|
if (!context.permissions.includes(permission)) {
|
|
return { code: "permission_denied", message: "bridge action is missing required permission" };
|
|
}
|
|
}
|
|
if (envelope.action === "ai.invoke" && (!envelope.aiPurpose || !context.aiPurposes.includes(envelope.aiPurpose))) {
|
|
return { code: "ai_purpose_denied", message: "AI purpose is not allowed for this plugin" };
|
|
}
|
|
const payload = envelope.payload ?? {};
|
|
if (Object.keys(payload).length > 16) {
|
|
return { code: "payload_too_large", message: "bridge payload has too many keys" };
|
|
}
|
|
const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0);
|
|
if (encodedSize > 16 * 1024) {
|
|
return { code: "payload_too_large", message: "bridge payload is too large" };
|
|
}
|
|
for (const key of Object.keys(payload)) {
|
|
if (!key.trim() || key !== key.trim()) {
|
|
return { code: "validation", message: "bridge payload key is invalid" };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface PluginArtifactReference {
|
|
artifactId: string;
|
|
filename: string;
|
|
contentType: string;
|
|
sizeBytes: number;
|
|
checksum: string;
|
|
downloadUrl: string;
|
|
expiresAt: string;
|
|
rangeSupported: boolean;
|
|
chunkSizeBytes: number;
|
|
storageBehavior?: string;
|
|
}
|
|
|
|
export function parsePluginArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | null {
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
const sizeBytes = Number(result.sizeBytes);
|
|
const chunkSizeBytes = Number(result.chunkSizeBytes);
|
|
const reference: PluginArtifactReference = {
|
|
artifactId: result.artifactId ?? "",
|
|
filename: result.filename ?? "artifact.bin",
|
|
contentType: result.contentType ?? "application/octet-stream",
|
|
sizeBytes,
|
|
checksum: result.checksum ?? "",
|
|
downloadUrl: result.downloadUrl ?? "",
|
|
expiresAt: result.expiresAt ?? "",
|
|
rangeSupported: result.rangeSupported === "true",
|
|
chunkSizeBytes,
|
|
storageBehavior: result.storageBehavior
|
|
};
|
|
if (!reference.artifactId || !Number.isFinite(sizeBytes) || sizeBytes <= 0 || !Number.isFinite(chunkSizeBytes) || chunkSizeBytes <= 0) {
|
|
return null;
|
|
}
|
|
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
|
|
return null;
|
|
}
|
|
return reference;
|
|
}
|
|
|
|
function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
|
|
switch (action) {
|
|
case "server.instances.read":
|
|
return ["server.read"];
|
|
case "jobs.dispatch":
|
|
return ["server.lifecycle"];
|
|
case "logs.query":
|
|
return ["server.logs.read"];
|
|
case "artifacts.open":
|
|
return ["server.artifacts.read"];
|
|
case "files.request":
|
|
return ["server.files.read"];
|
|
case "remote.access.request":
|
|
return ["server.remote.access"];
|
|
case "run.distribution.request":
|
|
return ["server.run.distribution"];
|
|
case "dependencies.request":
|
|
return ["server.dependencies.manage"];
|
|
case "logs.backfill.request":
|
|
return ["server.logs.read"];
|
|
case "plugin-lifecycle.request":
|
|
return ["server.lifecycle"];
|
|
case "ai.invoke":
|
|
return ["ai.invoke"];
|
|
default:
|
|
return [];
|
|
}
|
|
}
|