From d940ea67ca737f601677f65488f35126dd5371b1 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Fri, 4 Sep 2026 20:49:07 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Surface=20incomplete=20scatter-gath?= =?UTF-8?q?er=20results=20and=20server=20query=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServiceControl (Particular/ServiceControl#5848) now tells clients when a composite result is incomplete: a partial answer stays HTTP 200 with the data that did arrive plus an X-Particular-Incomplete-Results header naming every instance that contributed nothing (instanceId:timeout| unavailable|error), and a query nobody answered becomes a 504. All Messages reads both. A partial page keeps its data and shows a warning naming each missing instance and why. The instance id is ServiceControl's base64 API URL, decoded and shown as host and port with the URL as tooltip; the warning says what to do: the rows shown come from the instances that did answer, try again in a moment, or check the listed instances on Platform Health (linked), and when one timed out, that a narrower time range makes the query lighter. The results line presents the total as a floor while results are partial ("of at least 87,421,337"). A 504 gets a precise "exceeded the ServiceControl query time limit" message with the existing one-click narrower-range escape hatches. The state is forgotten with the results when the view is left. The header parser is shared (incompleteResults.ts) so the conversation and saga views can adopt it next. --- .../src/components/ResultsCount.spec.ts | 6 ++ src/Frontend/src/components/ResultsCount.vue | 4 +- .../src/components/audit/AuditList.spec.ts | 88 +++++++++++++++++++ .../src/components/audit/AuditList.vue | 48 +++++++++- .../src/components/incompleteResults.spec.ts | 62 +++++++++++++ .../src/components/incompleteResults.ts | 74 ++++++++++++++++ src/Frontend/src/stores/AuditStore.spec.ts | 49 ++++++++++- src/Frontend/src/stores/AuditStore.ts | 20 ++++- 8 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 src/Frontend/src/components/incompleteResults.spec.ts create mode 100644 src/Frontend/src/components/incompleteResults.ts diff --git a/src/Frontend/src/components/ResultsCount.spec.ts b/src/Frontend/src/components/ResultsCount.spec.ts index 84255aaf8..6e6aea171 100644 --- a/src/Frontend/src/components/ResultsCount.spec.ts +++ b/src/Frontend/src/components/ResultsCount.spec.ts @@ -10,6 +10,12 @@ describe("FEATURE: Results count", () => { expect(screen.getByText(expected)).toBeInTheDocument(); }); + test("EXAMPLE: A partial result presents its total as a floor", () => { + render(ResultsCount, { props: { displayed: 3, total: 87421337, incomplete: true } }); + + expect(screen.getByText(`Showing 3 of at least ${(87421337).toLocaleString()} result(s)`)).toBeInTheDocument(); + }); + test("EXAMPLE: The query duration is shown when known", () => { render(ResultsCount, { props: { displayed: 100, total: 500, durationMs: 2700 } }); diff --git a/src/Frontend/src/components/ResultsCount.vue b/src/Frontend/src/components/ResultsCount.vue index 5a90be26f..8f2db5095 100644 --- a/src/Frontend/src/components/ResultsCount.vue +++ b/src/Frontend/src/components/ResultsCount.vue @@ -5,6 +5,8 @@ import { useDateFormatter } from "@/composables/dateFormatter"; const props = defineProps<{ displayed: number; total: number; + // The total only covers the instances that answered (partial scatter-gather result) + incomplete?: boolean; durationMs?: number | null; completedAt?: string | null; }>(); @@ -34,7 +36,7 @@ const formattedDuration = computed(() => { diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index 9415b162c..c8a960aa7 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -450,6 +450,94 @@ describe("FEATURE: Audit Messages Query State", () => { }); }); + describe("RULE: Partial results name the instances whose data is missing", () => { + test("EXAMPLE: The warning lists each missing instance with its reason", async () => { + const { store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + + store.incompleteInstances = [ + { instanceId: "audit-2", reason: "timeout" }, + { instanceId: "audit-3", reason: "unavailable" }, + ]; + await nextTick(); + + const warning = screen.getByTestId("query-incomplete"); + expect(warning.textContent).toContain("audit-2 (timed out)"); + expect(warning.textContent).toContain("audit-3 (unreachable)"); + // the partial data itself stays on screen + expect(screen.queryAllByTestId("message-item").length).toBeGreaterThan(0); + }); + + test("EXAMPLE: The warning says what to do: retry, or check the health of those instances", async () => { + const { store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + + store.incompleteInstances = [{ instanceId: "audit-3", reason: "unavailable" }]; + await nextTick(); + + const warning = screen.getByTestId("query-incomplete"); + expect(warning.textContent).toContain("come from the instances that did answer"); + expect(warning.textContent).toContain("Try again in a moment"); + const healthLink = warning.querySelector("a")!; + expect(healthLink.textContent).toContain("health of those instances"); + expect(healthLink.getAttribute("href")).toContain("/platform-health"); + // nothing about the time range unless an instance timed out + expect(warning.textContent).not.toContain("narrower time range"); + }); + + test("EXAMPLE: When an instance timed out, the warning also suggests a narrower time range", async () => { + const { store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + + store.incompleteInstances = [{ instanceId: "audit-2", reason: "timeout" }]; + await nextTick(); + + expect(screen.getByTestId("query-incomplete").textContent).toContain("narrower time range"); + }); + + test("EXAMPLE: A ServiceControl instance id is shown as host and port, with the API URL on hover", async () => { + const { store } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + + store.incompleteInstances = [{ instanceId: "aHR0cDovL2xvY2FsaG9zdDo0NDQ0NC9hcGkv", reason: "timeout" }]; + await nextTick(); + + const warning = screen.getByTestId("query-incomplete"); + expect(warning.textContent).toContain("localhost:44444 (timed out)"); + expect(warning.textContent).not.toContain("aHR0"); + expect(warning.querySelector('[title="http://localhost:44444/api/"]')).not.toBeNull(); + }); + + test("EXAMPLE: No warning while a retry is in flight or when results are complete", async () => { + const { store, isRefreshing } = await renderAuditList([createMessage()]); + + await waitForFirstLoadToComplete(); + expect(screen.queryByTestId("query-incomplete")).not.toBeInTheDocument(); + + store.incompleteInstances = [{ instanceId: "audit-2", reason: "timeout" }]; + isRefreshing.value = true; + await nextTick(); + + expect(screen.queryByTestId("query-incomplete")).not.toBeInTheDocument(); + }); + + test("EXAMPLE: A query stopped by the server's time limit says so", async () => { + const { store } = await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + store.queryFailed = true; + store.queryTimedOut = true; + await nextTick(); + + expect(screen.getByTestId("query-error").textContent).toContain("exceeded the ServiceControl query time limit"); + }); + }); + describe("RULE: A query-control change results in exactly one query", () => { test("EXAMPLE: Changing the filter text fires a single query", async () => { const { refreshNow, store } = await renderAuditList([createMessage()]); diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index a16459b76..e5effe7fa 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -1,7 +1,8 @@