feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
|
||||
import type { components } from "./generated/schema";
|
||||
|
||||
type Schemas = components["schemas"];
|
||||
export type PeripheralContext = Schemas["Context"];
|
||||
export type PeripheralTask = Schemas["Task"];
|
||||
export type PeripheralTaskPage = Schemas["TaskPage"];
|
||||
export type PeripheralCreateTask = Schemas["CreateTask"];
|
||||
export type PeripheralTransition = Schemas["Transition"];
|
||||
export type PeripheralAddFeedback = Schemas["AddFeedback"];
|
||||
export type PeripheralScenarioState = Schemas["ScenarioState"];
|
||||
export type PeripheralScenario = PeripheralScenarioState["scenario"];
|
||||
export type PeripheralPointPage = Schemas["PointPage"];
|
||||
export type PeripheralAlertPage = Schemas["AlertPage"];
|
||||
export type PeripheralObservations = Schemas["Observations"];
|
||||
export type PeripheralDemoDataset = Schemas["DemoDataset"];
|
||||
export type PeripheralWorkOrder = Schemas["WorkOrder"];
|
||||
export type PeripheralWorkOrderPage = Schemas["WorkOrderPage"];
|
||||
export type PeripheralCreateWorkOrder = Schemas["CreateWorkOrder"];
|
||||
export type PeripheralUpdateWorkOrder = Schemas["UpdateWorkOrder"];
|
||||
export type PeripheralTestDataRecord = Schemas["TestDataRecord"];
|
||||
export type PeripheralTestDataRecordPage = Schemas["TestDataRecordPage"];
|
||||
export type PeripheralCreateTestDataRecord = Schemas["CreateTestDataRecord"];
|
||||
export type PeripheralUpdateTestDataRecord = Schemas["UpdateTestDataRecord"];
|
||||
export type PeripheralDeletedResource = Schemas["DeletedResource"];
|
||||
|
||||
type PeripheralMutationResult = PeripheralTask | PeripheralScenarioState | PeripheralWorkOrder |
|
||||
PeripheralTestDataRecord | PeripheralDeletedResource;
|
||||
|
||||
const API_PREFIX = "/api/sample/v1";
|
||||
|
||||
export class PeripheralSampleClient {
|
||||
private readonly baseUrl = env.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL;
|
||||
private projectIdPromise: Promise<string> | null = null;
|
||||
private readonly taskProjection = new Map<string, PeripheralTask>();
|
||||
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
|
||||
private scenarioProjection: PeripheralScenarioState | null = null;
|
||||
|
||||
constructor(private readonly getAccessToken?: AccessTokenProvider) {}
|
||||
|
||||
get configured() {
|
||||
return Boolean(this.baseUrl);
|
||||
}
|
||||
|
||||
getContext(signal?: AbortSignal) { return this.get<PeripheralContext>("/context", signal); }
|
||||
getScenario(signal?: AbortSignal) { return this.getScenarioProjected("/scenario", signal); }
|
||||
listPoints(signal?: AbortSignal) { return this.get<PeripheralPointPage>("/monitoring/points?limit=500", signal); }
|
||||
listAlerts(signal?: AbortSignal) { return this.get<PeripheralAlertPage>("/monitoring/alerts", signal); }
|
||||
getDemoData(signal?: AbortSignal) { return this.get<PeripheralDemoDataset>("/demo-data", signal); }
|
||||
listWorkOrders(signal?: AbortSignal) { return this.get<PeripheralWorkOrderPage>("/work-orders?limit=100", signal); }
|
||||
listTestData(signal?: AbortSignal) { return this.get<PeripheralTestDataRecordPage>("/test-data?limit=100", signal); }
|
||||
listObservations(pointId: string, start: string, end: string, signal?: AbortSignal) {
|
||||
const query = new URLSearchParams({ point_id: pointId, start, end });
|
||||
return this.get<PeripheralObservations>(`/monitoring/observations?${query}`, signal);
|
||||
}
|
||||
|
||||
async listTasks(cursor?: string | null, signal?: AbortSignal) {
|
||||
const query = new URLSearchParams({ limit: "20" });
|
||||
if (cursor) query.set("cursor", cursor);
|
||||
const page = await this.get<PeripheralTaskPage>(`/tasks?${query}`, signal);
|
||||
return { ...page, items: page.items.map((task) => this.projectTask(task)) };
|
||||
}
|
||||
|
||||
getTask(taskId: string, signal?: AbortSignal) {
|
||||
return this.getTaskProjected(`/tasks/${encodeURIComponent(taskId)}`, signal);
|
||||
}
|
||||
|
||||
async getReceipt(requestId: string, signal?: AbortSignal) {
|
||||
const receipt = await this.get<Schemas["Receipt"]>(
|
||||
`/requests/${encodeURIComponent(requestId)}`, signal
|
||||
);
|
||||
const result = this.projectResult(receipt.result);
|
||||
for (const [path, pending] of this.pendingCommands) {
|
||||
if (pending.requestId === requestId) this.pendingCommands.delete(path);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setScenario(scenario: PeripheralScenario, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralScenarioState>("/scenario", {
|
||||
request_id: crypto.randomUUID(), scenario
|
||||
}, signal);
|
||||
}
|
||||
|
||||
createTask(input: Omit<PeripheralCreateTask, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTask>("/tasks", {
|
||||
...input, request_id: crypto.randomUUID()
|
||||
}, signal);
|
||||
}
|
||||
|
||||
transition(taskId: string, input: Omit<PeripheralTransition, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTask>(
|
||||
`/tasks/${encodeURIComponent(taskId)}/transitions`,
|
||||
{ ...input, request_id: crypto.randomUUID() }, signal
|
||||
);
|
||||
}
|
||||
|
||||
addFeedback(taskId: string, input: Omit<PeripheralAddFeedback, "request_id" | "feedback_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTask>(
|
||||
`/tasks/${encodeURIComponent(taskId)}/feedback`,
|
||||
{ ...input, request_id: crypto.randomUUID(), feedback_id: crypto.randomUUID() }, signal
|
||||
);
|
||||
}
|
||||
|
||||
createWorkOrder(input: Omit<PeripheralCreateWorkOrder, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralWorkOrder>("/work-orders", {
|
||||
...input, request_id: crypto.randomUUID()
|
||||
}, signal);
|
||||
}
|
||||
|
||||
updateWorkOrder(workOrderId: string, input: Omit<PeripheralUpdateWorkOrder, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralWorkOrder>(
|
||||
`/work-orders/${encodeURIComponent(workOrderId)}`,
|
||||
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
|
||||
);
|
||||
}
|
||||
|
||||
deleteWorkOrder(workOrderId: string, expectedRevision: number, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralDeletedResource>(
|
||||
`/work-orders/${encodeURIComponent(workOrderId)}`,
|
||||
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
createTestData(input: Omit<PeripheralCreateTestDataRecord, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTestDataRecord>("/test-data", {
|
||||
...input, request_id: crypto.randomUUID()
|
||||
}, signal);
|
||||
}
|
||||
|
||||
updateTestData(recordId: string, input: Omit<PeripheralUpdateTestDataRecord, "request_id">, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralTestDataRecord>(
|
||||
`/test-data/${encodeURIComponent(recordId)}`,
|
||||
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
|
||||
);
|
||||
}
|
||||
|
||||
deleteTestData(recordId: string, expectedRevision: number, signal?: AbortSignal) {
|
||||
return this.postRecoverable<PeripheralDeletedResource>(
|
||||
`/test-data/${encodeURIComponent(recordId)}`,
|
||||
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
|
||||
);
|
||||
}
|
||||
|
||||
private async getTaskProjected(path: string, signal?: AbortSignal) {
|
||||
return this.projectTask(await this.get<PeripheralTask>(path, signal));
|
||||
}
|
||||
|
||||
private async getScenarioProjected(path: string, signal?: AbortSignal) {
|
||||
return this.projectScenario(await this.get<PeripheralScenarioState>(path, signal));
|
||||
}
|
||||
|
||||
private async postRecoverable<T extends PeripheralMutationResult>(
|
||||
path: string,
|
||||
payload: { request_id: string } & Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
method: "POST" | "PATCH" | "DELETE" = "POST"
|
||||
): Promise<T> {
|
||||
const commandPath = `${method}:${path}`;
|
||||
const serialized = JSON.stringify(payload);
|
||||
const fingerprint = JSON.stringify(
|
||||
Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "request_id"))
|
||||
);
|
||||
const pending = this.pendingCommands.get(commandPath);
|
||||
if (pending) {
|
||||
if (pending.payload !== fingerprint) {
|
||||
throw new PeripheralSampleError(
|
||||
"PENDING_COMMAND_MISMATCH",
|
||||
`此操作仍有未确认请求 ${pending.requestId},新内容未发送。请先查询原回执。`,
|
||||
"unknown",
|
||||
pending.requestId
|
||||
);
|
||||
}
|
||||
return this.recoverReceipt<T>(commandPath, pending.requestId, signal);
|
||||
}
|
||||
const headers = await this.prepareHeaders(signal, true);
|
||||
try {
|
||||
const result = await this.requestWithHeaders<T>(path, {
|
||||
method, body: serialized, signal, headers
|
||||
}, true);
|
||||
return this.projectResult(result) as T;
|
||||
} catch (error) {
|
||||
if (!(error instanceof PeripheralSampleError) || error.effect !== "unknown") throw error;
|
||||
this.pendingCommands.set(commandPath, { requestId: payload.request_id, payload: fingerprint });
|
||||
return this.recoverReceipt<T>(commandPath, payload.request_id, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverReceipt<T extends PeripheralMutationResult>(
|
||||
commandPath: string,
|
||||
requestId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
try {
|
||||
const receipt = await this.get<Schemas["Receipt"]>(
|
||||
`/requests/${encodeURIComponent(requestId)}`, signal
|
||||
);
|
||||
this.pendingCommands.delete(commandPath);
|
||||
return this.projectResult(receipt.result) as T;
|
||||
} catch (receiptError) {
|
||||
throw new PeripheralSampleError(
|
||||
"UNKNOWN_OUTCOME",
|
||||
`提交结果未知,未自动重试。再次操作只会查询原请求 ${requestId} 的回执。`,
|
||||
"unknown",
|
||||
requestId,
|
||||
{ cause: receiptError }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private get<T>(path: string, signal?: AbortSignal) {
|
||||
return this.request<T>(path, { signal });
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit): Promise<T> {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const headers = await this.prepareHeaders(init.signal ?? undefined, Boolean(init.body));
|
||||
return this.requestWithHeaders<T>(path, { ...init, headers }, false);
|
||||
}
|
||||
|
||||
private async requestWithHeaders<T>(path: string, init: RequestInit, write: boolean): Promise<T> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.baseUrl}${API_PREFIX}${path}`, init);
|
||||
} catch (error) {
|
||||
if (!write) throw error;
|
||||
throw new PeripheralSampleError("NETWORK_UNKNOWN", "连接中断,提交是否生效未知", "unknown", null, { cause: error });
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
if (write) throw new PeripheralSampleError("INVALID_RESPONSE", "响应无法解析,提交是否生效未知", "unknown", null, { cause: error });
|
||||
throw new PeripheralSampleError("INVALID_RESPONSE", "外围样板响应无法解析", "none", null, { cause: error });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = asSampleError(payload);
|
||||
throw new PeripheralSampleError(
|
||||
detail?.code ?? `HTTP_${response.status}`,
|
||||
detail?.message ?? `外围样板请求失败:HTTP ${response.status}`,
|
||||
detail?.effect ?? (write && response.status >= 500 ? "unknown" : "none"),
|
||||
detail?.request_id ?? null
|
||||
);
|
||||
}
|
||||
const expectedProjectId = new Headers(init.headers).get("X-Project-Id");
|
||||
if (expectedProjectId && !hasOnlyExpectedProjectIds(payload, expectedProjectId)) {
|
||||
throw invalidResponseError(write, "外围样板响应属于其他项目");
|
||||
}
|
||||
if (!write && !isRecognizedReadPayload(path, payload)) {
|
||||
throw invalidResponseError(false, "外围样板响应结构无效");
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
|
||||
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
|
||||
const projectId = await this.resolveProjectId(signal);
|
||||
const accessToken = await this.getAccessToken?.();
|
||||
return createWorkbenchHeaders(accessToken, projectId, json);
|
||||
}
|
||||
|
||||
private resolveProjectId(signal?: AbortSignal) {
|
||||
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken, signal)
|
||||
.then(({ project }) => project.project_id)
|
||||
.catch((error) => { this.projectIdPromise = null; throw error; });
|
||||
return this.projectIdPromise;
|
||||
}
|
||||
|
||||
private projectResult(result: PeripheralMutationResult) {
|
||||
if (isTask(result)) return this.projectTask(result);
|
||||
if (isScenarioState(result)) return this.projectScenario(result);
|
||||
if (isManagedResult(result)) return result;
|
||||
throw new PeripheralSampleError("INVALID_RESPONSE", "响应缺少可识别的样板资源,提交是否生效未知", "unknown", null);
|
||||
}
|
||||
|
||||
private projectScenario(next: PeripheralScenarioState) {
|
||||
if (this.scenarioProjection && next.revision < this.scenarioProjection.revision) {
|
||||
return this.scenarioProjection;
|
||||
}
|
||||
this.scenarioProjection = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
private projectTask(next: PeripheralTask) {
|
||||
const current = this.taskProjection.get(next.task_id);
|
||||
if (current && next.revision < current.revision) return current;
|
||||
const feedback = [...new Map(next.feedback.map((item) => [item.feedback_id, item])).values()];
|
||||
const projected = { ...next, feedback };
|
||||
this.taskProjection.set(next.task_id, projected);
|
||||
return projected;
|
||||
}
|
||||
}
|
||||
|
||||
function isTask(value: unknown): value is PeripheralTask {
|
||||
return Boolean(value && typeof value === "object" &&
|
||||
typeof (value as Partial<PeripheralTask>).task_id === "string" &&
|
||||
typeof (value as Partial<PeripheralTask>).revision === "number" &&
|
||||
Array.isArray((value as Partial<PeripheralTask>).feedback));
|
||||
}
|
||||
|
||||
function isScenarioState(value: unknown): value is PeripheralScenarioState {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<PeripheralScenarioState>;
|
||||
return typeof item.revision === "number" && typeof item.scenario === "string" &&
|
||||
["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"].includes(item.scenario);
|
||||
}
|
||||
|
||||
function isManagedResult(value: unknown): value is PeripheralWorkOrder | PeripheralTestDataRecord | PeripheralDeletedResource {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (item.deleted === true) return typeof item.resource_id === "string" &&
|
||||
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
|
||||
return (typeof item.work_order_id === "string" || typeof item.record_id === "string") &&
|
||||
typeof item.revision === "number" && item.origin === "synthetic";
|
||||
}
|
||||
|
||||
export class PeripheralSampleError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly effect: "none" | "unknown",
|
||||
readonly requestId: string | null,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "PeripheralSampleError";
|
||||
}
|
||||
}
|
||||
|
||||
function asSampleError(value: unknown): Schemas["SampleError"] | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const item = value as Partial<Schemas["SampleError"]>;
|
||||
if (typeof item.code !== "string" || typeof item.message !== "string") return null;
|
||||
if (item.effect !== "none" && item.effect !== "unknown") return null;
|
||||
return { code: item.code, message: item.message, effect: item.effect, request_id: item.request_id ?? null };
|
||||
}
|
||||
|
||||
function invalidResponseError(write: boolean, message: string) {
|
||||
return new PeripheralSampleError(
|
||||
"INVALID_RESPONSE",
|
||||
write ? `${message},提交是否生效未知` : message,
|
||||
write ? "unknown" : "none",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function hasOnlyExpectedProjectIds(value: unknown, expectedProjectId: string): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.every((item) => hasOnlyExpectedProjectIds(item, expectedProjectId));
|
||||
}
|
||||
if (!value || typeof value !== "object") return true;
|
||||
const item = value as Record<string, unknown>;
|
||||
if ("project_id" in item && item.project_id !== expectedProjectId) return false;
|
||||
return Object.values(item).every((child) => hasOnlyExpectedProjectIds(child, expectedProjectId));
|
||||
}
|
||||
|
||||
function isRecognizedReadPayload(path: string, value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Record<string, unknown>;
|
||||
if (path === "/context") {
|
||||
return typeof item.project_id === "string"
|
||||
&& item.protocol_version === "tjwater-peripheral-sample@1";
|
||||
}
|
||||
if (path === "/scenario") return isScenarioState(value);
|
||||
if (path.startsWith("/requests/")) {
|
||||
return typeof item.request_id === "string" && "result" in item;
|
||||
}
|
||||
if (path.startsWith("/tasks?")) {
|
||||
return isPageOf(value, isTask) && (item.next_cursor === null || typeof item.next_cursor === "string");
|
||||
}
|
||||
if (path.startsWith("/tasks/")) return isTask(value);
|
||||
if (path.startsWith("/monitoring/points")) return isPageOf(value, isPoint);
|
||||
if (path.startsWith("/monitoring/alerts")) return isPageOf(value, isAlert);
|
||||
if (path.startsWith("/monitoring/observations")) {
|
||||
return isPageOf(value, isObservation) && typeof item.truncated === "boolean";
|
||||
}
|
||||
if (path === "/demo-data") {
|
||||
return item.origin === "synthetic"
|
||||
&& typeof item.generator_version === "string"
|
||||
&& Array.isArray(item.points)
|
||||
&& Array.isArray(item.observations);
|
||||
}
|
||||
if (path.startsWith("/work-orders")) {
|
||||
return isPageOf(value, (entry) => isManagedResult(entry) && "work_order_id" in entry);
|
||||
}
|
||||
if (path.startsWith("/test-data")) {
|
||||
return isPageOf(value, (entry) => isManagedResult(entry) && "record_id" in entry);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPageOf(
|
||||
value: unknown,
|
||||
predicate: (item: Record<string, unknown>) => boolean
|
||||
): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const items = (value as Record<string, unknown>).items;
|
||||
return Array.isArray(items) && items.every(
|
||||
(item) => Boolean(item && typeof item === "object" && predicate(item as Record<string, unknown>))
|
||||
);
|
||||
}
|
||||
|
||||
function isPoint(item: Record<string, unknown>) {
|
||||
return typeof item.point_id === "string" && typeof item.label === "string";
|
||||
}
|
||||
|
||||
function isAlert(item: Record<string, unknown>) {
|
||||
return typeof item.alert_id === "string" && typeof item.title === "string";
|
||||
}
|
||||
|
||||
function isObservation(item: Record<string, unknown>) {
|
||||
return typeof item.record_id === "string" && typeof item.point_id === "string";
|
||||
}
|
||||
Reference in New Issue
Block a user