mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): paginate ambiguous run candidates
This commit is contained in:
@@ -128,7 +128,11 @@ function unavailableResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function ambiguousResult(runId: string, executionId: string): AuditRunInspectResult {
|
||||
function ambiguousResult(
|
||||
runId: string,
|
||||
executionId: string,
|
||||
nextExecutionCursor?: string,
|
||||
): AuditRunInspectResult {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
run: { runId, status: "known" },
|
||||
@@ -148,6 +152,7 @@ function ambiguousResult(runId: string, executionId: string): AuditRunInspectRes
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["execution.selection"] },
|
||||
...(nextExecutionCursor ? { nextExecutionCursor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,14 +304,22 @@ describeControlUiE2e("Control UI durable Activity run inspector", () => {
|
||||
reasonCode: "identity_context_corrupt",
|
||||
}),
|
||||
},
|
||||
{
|
||||
match: { runId: "ambiguous", executionCursor: "50" },
|
||||
response: ambiguousResult("ambiguous", "execution-candidate-51"),
|
||||
},
|
||||
{
|
||||
match: { runId: "ambiguous" },
|
||||
response: ambiguousResult("ambiguous", "execution-candidate-1"),
|
||||
response: ambiguousResult("ambiguous", "execution-candidate-1", "50"),
|
||||
},
|
||||
{
|
||||
match: { executionId: "execution-candidate-1" },
|
||||
response: presentResult("ambiguous", "execution-candidate-1"),
|
||||
},
|
||||
{
|
||||
match: { executionId: "execution-candidate-51" },
|
||||
response: presentResult("ambiguous", "execution-candidate-51"),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -331,11 +344,20 @@ describeControlUiE2e("Control UI durable Activity run inspector", () => {
|
||||
await page.goto(`${server.baseUrl}activity?view=run&run=ambiguous`);
|
||||
await page.getByRole("heading", { name: "Multiple executions match this run" }).waitFor();
|
||||
await screenshot(page, "11-ambiguous.png");
|
||||
await page.getByRole("link", { name: "execution-candidate-1" }).click();
|
||||
await page.getByRole("heading", { name: "Identity and authority" }).waitFor();
|
||||
expect(new URL(page.url()).searchParams.get("execution")).toBe("execution-candidate-1");
|
||||
await page.getByRole("button", { name: "Load more executions" }).click();
|
||||
await page.getByRole("link", { name: "execution-candidate-51" }).waitFor();
|
||||
expect((await gateway.getRequests("audit.run.inspect")).at(-1)?.params).toEqual({
|
||||
executionId: "execution-candidate-1",
|
||||
runId: "ambiguous",
|
||||
executionCursor: "50",
|
||||
decisionLimit: 50,
|
||||
executionLimit: 50,
|
||||
});
|
||||
expect(await page.getByRole("button", { name: "Load more executions" }).count()).toBe(0);
|
||||
await page.getByRole("link", { name: "execution-candidate-51" }).click();
|
||||
await page.getByRole("heading", { name: "Identity and authority" }).waitFor();
|
||||
expect(new URL(page.url()).searchParams.get("execution")).toBe("execution-candidate-51");
|
||||
expect((await gateway.getRequests("audit.run.inspect")).at(-1)?.params).toEqual({
|
||||
executionId: "execution-candidate-51",
|
||||
decisionLimit: 50,
|
||||
});
|
||||
await screenshot(page, "12-exact-selection.png");
|
||||
|
||||
@@ -173,7 +173,10 @@ const enActivity = {
|
||||
listLabel: "Matching executions",
|
||||
recorded: "Recorded {date}",
|
||||
executionReference: "Inspect execution",
|
||||
more: "More matching executions exist beyond this bounded page. Use the audit CLI to continue discovery and select one exact execution.",
|
||||
more: "More matching executions exist beyond this bounded page.",
|
||||
loadMore: "Load more executions",
|
||||
loadingMore: "Loading executions…",
|
||||
loadMoreError: "More executions could not be loaded. Try again.",
|
||||
},
|
||||
panels: {
|
||||
empty: {
|
||||
|
||||
@@ -209,13 +209,16 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
gateway: ApplicationContext["gateway"],
|
||||
client: GatewayBrowserClient,
|
||||
selector: RunInspectorSelector,
|
||||
previousResult?: AuditRunInspectResult,
|
||||
) {
|
||||
this.cancelInspectorRequest();
|
||||
const epoch = this.inspectorEpoch;
|
||||
const abort = new AbortController();
|
||||
this.inspectorAbort = abort;
|
||||
this.inspectorClient = client;
|
||||
this.runInspector = { status: "loading", waitingForGateway: false };
|
||||
this.runInspector = previousResult
|
||||
? { status: "ready", result: previousResult, executionPageStatus: "loading" }
|
||||
: { status: "loading", waitingForGateway: false };
|
||||
const requestSelectorKey = selectorKey(selector);
|
||||
const isCurrent = () =>
|
||||
this.inspectorEpoch === epoch &&
|
||||
@@ -227,13 +230,42 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
try {
|
||||
const params =
|
||||
selector.kind === "run"
|
||||
? { runId: selector.id, decisionLimit: 50, executionLimit: 50 }
|
||||
? {
|
||||
runId: selector.id,
|
||||
decisionLimit: 50,
|
||||
executionLimit: 50,
|
||||
...(previousResult?.nextExecutionCursor
|
||||
? { executionCursor: previousResult.nextExecutionCursor }
|
||||
: {}),
|
||||
}
|
||||
: { executionId: selector.id, decisionLimit: 50 };
|
||||
const result = await client.request<AuditRunInspectResult>("audit.run.inspect", params, {
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (isCurrent()) {
|
||||
this.runInspector = { status: "ready", result };
|
||||
if (
|
||||
previousResult?.identity.state === "ambiguous" &&
|
||||
result.identity.state === "ambiguous"
|
||||
) {
|
||||
const candidates = new Map(
|
||||
previousResult.identity.candidates.map((candidate) => [
|
||||
candidate.executionId,
|
||||
candidate,
|
||||
]),
|
||||
);
|
||||
for (const candidate of result.identity.candidates) {
|
||||
candidates.set(candidate.executionId, candidate);
|
||||
}
|
||||
this.runInspector = {
|
||||
status: "ready",
|
||||
result: {
|
||||
...result,
|
||||
identity: { ...result.identity, candidates: [...candidates.values()] },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.runInspector = { status: "ready", result };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent() || abort.signal.aborted) {
|
||||
@@ -243,7 +275,9 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
? { status: "unauthorized" }
|
||||
: this.isUnknownInspectMethod(error)
|
||||
? { status: "unsupported" }
|
||||
: { status: "error" };
|
||||
: previousResult
|
||||
? { status: "ready", result: previousResult, executionPageStatus: "error" }
|
||||
: { status: "error" };
|
||||
} finally {
|
||||
if (this.inspectorAbort === abort) {
|
||||
this.inspectorAbort = null;
|
||||
@@ -251,6 +285,30 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private loadMoreExecutions() {
|
||||
const route = this.routeData;
|
||||
const snapshot = this.context.gateway.snapshot;
|
||||
const inspectorState = this.runInspector;
|
||||
if (
|
||||
route?.mode !== "run" ||
|
||||
route.selector?.kind !== "run" ||
|
||||
snapshot.phase !== "connected" ||
|
||||
!snapshot.client ||
|
||||
inspectorState.status !== "ready" ||
|
||||
inspectorState.executionPageStatus === "loading" ||
|
||||
inspectorState.result.identity.state !== "ambiguous" ||
|
||||
!inspectorState.result.nextExecutionCursor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.loadRunInspector(
|
||||
this.context.gateway,
|
||||
snapshot.client,
|
||||
route.selector,
|
||||
inspectorState.result,
|
||||
);
|
||||
}
|
||||
|
||||
private selectMode(mode: "live" | "run") {
|
||||
if (mode === "live") {
|
||||
this.context.navigate("activity", { search: "" });
|
||||
@@ -397,6 +455,7 @@ class ActivityPage extends OpenClawLightDomElement {
|
||||
? renderRunInspector({
|
||||
basePath: this.context.basePath,
|
||||
state: this.runInspector,
|
||||
onLoadMoreExecutions: () => this.loadMoreExecutions(),
|
||||
onRetry: () =>
|
||||
this.syncRunInspector(this.context.gateway, this.context.gateway.snapshot, true),
|
||||
})
|
||||
|
||||
@@ -29,7 +29,11 @@ export type RunInspectorState =
|
||||
| { status: "unauthorized" }
|
||||
| { status: "unsupported" }
|
||||
| { status: "error" }
|
||||
| { status: "ready"; result: AuditRunInspectResult };
|
||||
| {
|
||||
status: "ready";
|
||||
result: AuditRunInspectResult;
|
||||
executionPageStatus?: "loading" | "error";
|
||||
};
|
||||
|
||||
type RunInspectorDiagnosticKind =
|
||||
| "present"
|
||||
|
||||
@@ -108,10 +108,18 @@ function unavailableResult(
|
||||
};
|
||||
}
|
||||
|
||||
function renderState(state: RunInspectorState) {
|
||||
function renderState(state: RunInspectorState, onLoadMoreExecutions = vi.fn()) {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(renderRunInspector({ basePath: "/operator", state, onRetry: vi.fn() }), container);
|
||||
render(
|
||||
renderRunInspector({
|
||||
basePath: "/operator",
|
||||
state,
|
||||
onLoadMoreExecutions,
|
||||
onRetry: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -208,14 +216,29 @@ describe("renderRunInspector", () => {
|
||||
},
|
||||
decisions: [],
|
||||
coverage: { state: "unknown", missingEvidence: ["execution.selection"] },
|
||||
nextExecutionCursor: "opaque-cursor",
|
||||
};
|
||||
|
||||
const link = renderState({ status: "ready", result }).querySelector<HTMLAnchorElement>(
|
||||
'a[href*="execution="]',
|
||||
);
|
||||
const onLoadMoreExecutions = vi.fn();
|
||||
const container = renderState({ status: "ready", result }, onLoadMoreExecutions);
|
||||
const link = container.querySelector<HTMLAnchorElement>('a[href*="execution="]');
|
||||
expect(link?.textContent).toContain("execution:a/b");
|
||||
expect(link?.getAttribute("href")).toBe(
|
||||
"/operator/activity?view=run&execution=execution%3Aa%2Fb",
|
||||
);
|
||||
const loadMore = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Load more executions"),
|
||||
);
|
||||
loadMore?.click();
|
||||
expect(onLoadMoreExecutions).toHaveBeenCalledOnce();
|
||||
|
||||
const loading = renderState({ status: "ready", result, executionPageStatus: "loading" });
|
||||
expect(loading.querySelector("button")?.disabled).toBe(true);
|
||||
expect(loading.textContent).toContain("Loading executions…");
|
||||
|
||||
const failed = renderState({ status: "ready", result, executionPageStatus: "error" });
|
||||
expect(failed.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
"More executions could not be loaded",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ type EvidenceState = "present" | "absent" | "unknown" | "unsupported";
|
||||
type RunInspectorProps = {
|
||||
basePath: string;
|
||||
state: RunInspectorState;
|
||||
onLoadMoreExecutions: () => void;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
@@ -435,7 +436,12 @@ function diagnosticCopy(result: AuditRunInspectResult) {
|
||||
return unreachable;
|
||||
}
|
||||
|
||||
function renderUnavailableResult(result: AuditRunInspectResult, basePath: string) {
|
||||
function renderUnavailableResult(
|
||||
result: AuditRunInspectResult,
|
||||
basePath: string,
|
||||
executionPageStatus: "loading" | "error" | undefined,
|
||||
onLoadMoreExecutions: () => void,
|
||||
) {
|
||||
const copy = diagnosticCopy(result);
|
||||
if (!copy || result.identity.state === "present") {
|
||||
return nothing;
|
||||
@@ -472,8 +478,23 @@ function renderUnavailableResult(result: AuditRunInspectResult, basePath: string
|
||||
)}
|
||||
</ol>
|
||||
${result.nextExecutionCursor
|
||||
? html`<div class="run-inspector__pagination" role="note">
|
||||
${t("activity.runInspector.candidates.more")}
|
||||
? html`<div class="run-inspector__pagination">
|
||||
<span>${t("activity.runInspector.candidates.more")}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
?disabled=${executionPageStatus === "loading"}
|
||||
@click=${onLoadMoreExecutions}
|
||||
>
|
||||
${executionPageStatus === "loading"
|
||||
? t("activity.runInspector.candidates.loadingMore")
|
||||
: t("activity.runInspector.candidates.loadMore")}
|
||||
</button>
|
||||
${executionPageStatus === "error"
|
||||
? html`<span role="alert">
|
||||
${t("activity.runInspector.candidates.loadMoreError")}
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
`
|
||||
@@ -482,7 +503,12 @@ function renderUnavailableResult(result: AuditRunInspectResult, basePath: string
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReady(result: AuditRunInspectResult, basePath: string) {
|
||||
function renderReady(
|
||||
state: Extract<RunInspectorState, { status: "ready" }>,
|
||||
basePath: string,
|
||||
onLoadMoreExecutions: () => void,
|
||||
) {
|
||||
const result = state.result;
|
||||
const currentCoverageLabel = coverageLabel(result.coverage.state);
|
||||
return html`
|
||||
<div
|
||||
@@ -509,7 +535,7 @@ function renderReady(result: AuditRunInspectResult, basePath: string) {
|
||||
</section>
|
||||
${renderMissingEvidence(result.coverage.missingEvidence)} ${renderDecisions(result)}
|
||||
`
|
||||
: renderUnavailableResult(result, basePath)}
|
||||
: renderUnavailableResult(result, basePath, state.executionPageStatus, onLoadMoreExecutions)}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -582,7 +608,7 @@ export function renderRunInspector(props: RunInspectorProps) {
|
||||
);
|
||||
break;
|
||||
case "ready":
|
||||
content = renderReady(state.result, props.basePath);
|
||||
content = renderReady(state, props.basePath, props.onLoadMoreExecutions);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.run-inspector__pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.run-inspector__coverage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
Reference in New Issue
Block a user