-
+
-
- {{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
-
+
+
+ {{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
+
+
diff --git a/web/src/views/OnCall/OnCallResponseDetail.spec.ts b/web/src/views/OnCall/OnCallResponseDetail.spec.ts
index 489dbb4d3b..7d8388c955 100644
--- a/web/src/views/OnCall/OnCallResponseDetail.spec.ts
+++ b/web/src/views/OnCall/OnCallResponseDetail.spec.ts
@@ -19,6 +19,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/locales";
import { queryClient } from "@/composables/query/queryClient";
import alertsService from "@/services/alerts";
+import { alertKeys } from "@/services/alerts.querykeys";
import oncallService from "@/services/oncall";
import { responseProgressQuery } from "@/services/oncall.queries";
import { oncallKeys } from "@/services/oncall.querykeys";
@@ -62,7 +63,7 @@ vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }))
import analytics from "@/services/product_analytics";
vi.mock("@/services/alerts", () => ({
- default: { get_by_alert_id: vi.fn() },
+ default: { get_by_alert_id: vi.fn(), listByFolderId: vi.fn() },
}));
/// Escalate's whole point is what it reports back, and the report is a toast.
@@ -702,6 +703,33 @@ describe("OnCallResponseDetail", () => {
);
});
+ /// A cached org-wide list filled after the page opened settles it with no request at all.
+ it("learns the alert is gone from an already cached alert list", async () => {
+ queryClient.setQueryData(
+ alertKeys.dependencies(store.state.selectedOrganization.identifier),
+ [{ alert_id: "al_other" }],
+ );
+ const wrapper = await renderWith(held());
+
+ expect(alerts.get_by_alert_id).not.toHaveBeenCalled();
+ expect(alerts.listByFolderId).not.toHaveBeenCalled();
+ expect(unackBtn(wrapper).findComponent({ name: "OTooltip" }).props("content")).toBe(
+ "Can't unacknowledge: this page's alert was deleted",
+ );
+ queryClient.removeQueries({
+ queryKey: alertKeys.dependencies(store.state.selectedOrganization.identifier),
+ });
+ });
+
+ it("asks for the alert itself when no list is cached", async () => {
+ alerts.get_by_alert_id.mockResolvedValue({ data: {} } as any);
+ const wrapper = await renderWith(held());
+
+ expect(alerts.listByFolderId).not.toHaveBeenCalled();
+ expect(alerts.get_by_alert_id).toHaveBeenCalledWith(expect.any(String), "al_ckt");
+ expect(unackBtn(wrapper).props("disabled")).toBe(false);
+ });
+
it("keeps the button on when the alert lookup failed for another reason", async () => {
alerts.get_by_alert_id.mockRejectedValue({ response: { status: 500 } });
const wrapper = await renderWith(held());
diff --git a/web/src/views/OnCall/OnCallResponseDetail.vue b/web/src/views/OnCall/OnCallResponseDetail.vue
index 82212eb27d..8425ddbc72 100644
--- a/web/src/views/OnCall/OnCallResponseDetail.vue
+++ b/web/src/views/OnCall/OnCallResponseDetail.vue
@@ -924,6 +924,7 @@ import analytics from "@/services/product_analytics";
import OTextarea from "@/lib/forms/Input/OTextarea.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import alertsService from "@/services/alerts";
+import { alertKeys } from "@/services/alerts.querykeys";
import { useMutation } from "@tanstack/vue-query";
import { queryClient } from "@/composables/query/queryClient";
import {
@@ -1724,8 +1725,13 @@ async function fetchSubjectAlert() {
subjectAlert.value = null;
subjectAlertMissing.value = false;
if (response.value?.subject.subject_type !== "alert") return;
+ const alertId = response.value.subject.source_id;
+ if (cachedAlertMissing(alertId, response.value.opened_at)) {
+ subjectAlertMissing.value = true;
+ return;
+ }
try {
- const res = await alertsService.get_by_alert_id(orgId.value, response.value.subject.source_id);
+ const res = await alertsService.get_by_alert_id(orgId.value, alertId);
subjectAlert.value = res.data ?? null;
} catch (err: any) {
subjectAlert.value = null;
@@ -1733,6 +1739,15 @@ async function fetchSubjectAlert() {
}
}
+/// Only an org-wide list already cached since the page opened can say the alert is gone; anything else asks the alert itself.
+function cachedAlertMissing(alertId: string, openedAt: number): boolean {
+ const key = alertKeys.dependencies(orgId.value);
+ const list = queryClient.getQueryData<{ alert_id?: string }[]>(key);
+ const fetchedAt = queryClient.getQueryState(key)?.dataUpdatedAt ?? 0;
+ if (!Array.isArray(list) || fetchedAt * 1000 < openedAt) return false;
+ return !list.some((alert) => alert.alert_id === alertId);
+}
+
async function fetchIncidentTitle() {
const incidentId = response.value?.incident_id;
if (!incidentId) {
diff --git a/web/src/views/OnCall/OnCallResponses.spec.ts b/web/src/views/OnCall/OnCallResponses.spec.ts
index 53a57e6046..397f70d6e3 100644
--- a/web/src/views/OnCall/OnCallResponses.spec.ts
+++ b/web/src/views/OnCall/OnCallResponses.spec.ts
@@ -738,16 +738,15 @@ describe("OnCallResponses", () => {
expect(service.escalationProgress).not.toHaveBeenCalled();
});
- /// One request per page and no bulk endpoint: past the cap the list says so
- /// rather than leaving a blank cell that reads as "nothing has fired".
- it("caps how many ladders it loads, and says that it did", async () => {
+ /// One request per page and no bulk endpoint; a row past the cap speaks for itself, so the footer stays quiet.
+ it("caps how many ladders it loads without a footer note about it", async () => {
const many = Array.from({ length: 30 }, (_, i) =>
page({ id: `resp_${i}`, opened_at: 1_700_000_000_000_000 + i }, `al_${i}`),
);
const wrapper = await withPages(many);
expect(service.escalationProgress).toHaveBeenCalledTimes(25);
- expect(wrapper.find('[data-test="oncall-escalation-capped"]').exists()).toBe(true);
+ expect(wrapper.find('[data-test="oncall-escalation-capped"]').exists()).toBe(false);
});
/// Rows past the cap have no progress: the record's own step, or no claim, never "Not paged yet".
diff --git a/web/src/views/OnCall/OnCallResponses.vue b/web/src/views/OnCall/OnCallResponses.vue
index 081630d91e..0005a41a58 100644
--- a/web/src/views/OnCall/OnCallResponses.vue
+++ b/web/src/views/OnCall/OnCallResponses.vue
@@ -293,15 +293,10 @@ along with this program. If not, see