@@ -79,6 +79,9 @@ describe("OnCallFiringHistory", () => {
it("explains an empty history rather than showing a bare frame", () => {
const wrapper = render([]);
expect(wrapper.find('[data-test="oncall-firing-history-empty"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain("No earlier firings");
+ // A pager reading "0 - 0 of 0" under an empty state says nothing.
+ expect(wrapper.findComponent({ name: "OTable" }).props("pagination")).toBe("none");
});
/// Before `loading` existed, an in-flight fetch and a genuinely first-ever
diff --git a/web/src/components/oncall/OnCallFiringHistory.vue b/web/src/components/oncall/OnCallFiringHistory.vue
index 4b267cb321..95d865fdea 100644
--- a/web/src/components/oncall/OnCallFiringHistory.vue
+++ b/web/src/components/oncall/OnCallFiringHistory.vue
@@ -33,7 +33,7 @@ along with this program. If not, see
.
row-key="id"
:frame="false"
:loading="loading"
- pagination="client"
+ :pagination="firings.length ? 'client' : 'none'"
:show-global-filter="false"
table-id="oncall-firing-history"
data-test="oncall-firing-history-table"
@@ -61,13 +61,12 @@ along with this program. If not, see
.
{{ ABSENT }}
-
diff --git a/web/src/components/oncall/OnCallMyDeliveries.spec.ts b/web/src/components/oncall/OnCallMyDeliveries.spec.ts
index c949b01d28..15c3bfed77 100644
--- a/web/src/components/oncall/OnCallMyDeliveries.spec.ts
+++ b/web/src/components/oncall/OnCallMyDeliveries.spec.ts
@@ -151,7 +151,13 @@ describe("OnCallMyDeliveries", () => {
answer([
delivery({ event_id: "ev_1", response_id: "resp_1", at: AT }),
delivery({ event_id: "ev_2", response_id: "resp_2", at: AT + 1 }),
- delivery({ event_id: "ev_3", response_id: "resp_3", subject_id: "al_other", at: AT + 2 }),
+ delivery({
+ event_id: "ev_3",
+ response_id: "resp_3",
+ subject_id: "al_other",
+ title: "disk full",
+ at: AT + 2,
+ }),
]);
const wrapper = render();
await flushPromises();
@@ -160,6 +166,35 @@ describe("OnCallMyDeliveries", () => {
expect(historyRows(wrapper)[1].text()).toContain("×2");
});
+ /// An alert and the incident it opened page under one name; two rows the reader cannot tell apart are one.
+ it("groups pages that share a name across subjects and teams", async () => {
+ answer([
+ delivery({ event_id: "ev_1", response_id: "resp_1", team_id: "team_dev", at: AT }),
+ delivery({
+ event_id: "ev_2",
+ response_id: "resp_2",
+ subject_type: "incident",
+ subject_id: "inc_1#1",
+ team_id: "team_dev",
+ at: AT + 1,
+ }),
+ delivery({
+ event_id: "ev_3",
+ response_id: "resp_3",
+ subject_type: "incident",
+ subject_id: "inc_2#1",
+ team_id: "team_sre",
+ at: AT + 2,
+ }),
+ ]);
+ const wrapper = render({ teamNames: { team_dev: "Dev Team", team_sre: "Platform SRE" } });
+ await flushPromises();
+
+ expect(historyRows(wrapper)).toHaveLength(1);
+ expect(historyRows(wrapper)[0].text()).toContain("×3");
+ expect(historyRows(wrapper)[0].text()).toContain("Platform SRE, Dev Team");
+ });
+
/// The subject id ends in the firing number, so two firings of one alert are still one row.
it("groups an alert's firings into one row", async () => {
answer([
@@ -296,7 +331,9 @@ describe("OnCallMyDeliveries", () => {
answer([delivery({ read: true, response_state: "resolved" })]);
const wrapper = render();
await flushPromises();
- expect(wrapper.find('[data-test="oncall-my-deliveries-unread-toggle"]').exists()).toBe(false);
+ expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).toContain(
+ "invisible",
+ );
expect(wrapper.find('[data-test="oncall-my-delivery-more-ev_88"]').exists()).toBe(true);
const toggle = wrapper.findComponent('[data-test="oncall-my-delivery-toggle-ev_88"]');
expect(toggle.props("iconLeft")).toBe("mail");
@@ -307,11 +344,35 @@ describe("OnCallMyDeliveries", () => {
);
});
- /// An empty list has nothing to narrow, so it offers no filter.
- it("offers no unread filter on an empty list", async () => {
+ /// An empty list has nothing to narrow, so the filter is hidden; it keeps its slot so the header never reflows.
+ it("hides the unread filter on an empty list without removing its slot", async () => {
const wrapper = render();
await flushPromises();
- expect(wrapper.find('[data-test="oncall-my-deliveries-unread-toggle"]').exists()).toBe(false);
+ expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).toContain(
+ "invisible",
+ );
+ });
+
+ it("shows the filter and Mark all read once something is unread", async () => {
+ answer([delivery({ read: false })]);
+ const wrapper = render();
+ await flushPromises();
+ expect(wrapper.find('[data-test="oncall-my-deliveries-controls"]').classes()).not.toContain(
+ "invisible",
+ );
+ expect(wrapper.find('[data-test="oncall-my-deliveries-read-all"]').classes()).not.toContain(
+ "invisible",
+ );
+ });
+
+ /// A reader on no team has no page to test, so the empty history points them at a team instead.
+ it("offers Join a team on an empty history when the reader is on no team", async () => {
+ const wrapper = render({ noTeam: true });
+ await flushPromises();
+ const action = wrapper.find('[data-test="empty-action"]');
+ expect(action.text()).toBe("Join a team");
+ await action.trigger("click");
+ expect(wrapper.emitted("join-team")).toHaveLength(1);
});
/// Unticking answers from the cache at once, so the slower "unread only" answer must not narrow a widened inbox.
@@ -476,8 +537,8 @@ describe("OnCallMyDeliveries", () => {
);
});
- /// A column of empty dots says nothing, so the unread column only exists while something is unread.
- it("leaves out the unread column when nothing is unread", async () => {
+ /// The dot column is always there: arriving with the first unread row shifted every other column. Actions are not pinned.
+ it("keeps the unread column when nothing is unread", async () => {
answer([delivery({ read: true })]);
const wrapper = render();
await flushPromises();
@@ -486,13 +547,15 @@ describe("OnCallMyDeliveries", () => {
id: string;
}[];
expect(columns.map((c) => c.id)).toEqual([
+ "unread",
"page",
"team",
"state",
"channels",
"at",
- "actions",
+ "row-actions",
]);
+ expect(columns.some((c) => "pinned" in c)).toBe(false);
});
it("names the team that the empty history's test page goes to", async () => {
diff --git a/web/src/components/oncall/OnCallMyDeliveries.vue b/web/src/components/oncall/OnCallMyDeliveries.vue
index 6347239bee..c363be0bc6 100644
--- a/web/src/components/oncall/OnCallMyDeliveries.vue
+++ b/web/src/components/oncall/OnCallMyDeliveries.vue
@@ -109,7 +109,12 @@ along with this program. If not, see
.
{{ t("oncall.myDeliveriesUnread", { count: unread }, unread) }}
-
+
+
.
@update:model-value="(v: CheckboxModelValue) => setUnreadOnly(!!v)"
/>
.
{{ raw(clock(row.latest.at)) }}
-
+
.
size="block"
icon="notifications-active"
:title="unreadOnly ? t('oncall.myDeliveriesNoUnread') : t('oncall.myDeliveriesNone')"
- :action-label="
- unreadOnly || !canTest
- ? undefined
- : testTeamName
- ? t('oncall.mineTestTeam', { team: raw(testTeamName) })
- : t('oncall.mineTestTeamPage')
- "
- action-icon="campaign"
+ :action-label="emptyAction?.label"
+ :action-icon="emptyAction?.icon"
data-test="oncall-my-deliveries-empty"
- @action="emit('test-page')"
+ @action="emptyAction?.run()"
/>
@@ -307,7 +306,7 @@ import {
} from "@/services/oncall.queries";
import type { Channel, MyDeliveries, MyDelivery } from "@/ts/interfaces/oncall";
import type { IconName } from "@/lib/core/Icon/OIcon.types";
-import { raw, useI18nTyped } from "@/types/i18n";
+import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { useOnCallClock } from "@/composables/useOnCallClock";
import { formatOnCallInstant, isEscalating } from "@/utils/oncall";
@@ -321,11 +320,19 @@ const props = withDefaults(
canTest?: boolean;
/** The team that test page goes to, named on the button so it is never ambiguous. */
testTeamName?: string | null;
+ /** The reader is on no team, so the empty history points them at one instead. */
+ noTeam?: boolean;
}>(),
- { teamNames: () => ({}), teamsState: "loaded", canTest: false, testTeamName: null },
+ {
+ teamNames: () => ({}),
+ teamsState: "loaded",
+ canTest: false,
+ testTeamName: null,
+ noTeam: false,
+ },
);
-const emit = defineEmits<{ unread: [count: number]; "test-page": [] }>();
+const emit = defineEmits<{ unread: [count: number]; "test-page": []; "join-team": [] }>();
/// A first visit has a handful of pages, so more skeleton rows would only collapse when they land.
const HISTORY_SKELETON_ROWS = 3;
@@ -401,6 +408,23 @@ const teamsPending = (...ids: string[]) =>
const countsAsUnread = (row: MyDelivery) => !row.read;
+/// An empty history always says what to do next: test a team's page, or find a team to be paged by.
+const emptyAction = computed<{ label: I18nText; icon: IconName; run: () => void } | null>(() => {
+ if (unreadOnly.value) return null;
+ if (props.canTest) {
+ return {
+ label: props.testTeamName
+ ? t("oncall.mineTestTeam", { team: raw(props.testTeamName) })
+ : t("oncall.mineTestTeamPage"),
+ icon: "campaign",
+ run: () => emit("test-page"),
+ };
+ }
+ return props.noTeam
+ ? { label: t("oncall.mineJoinTeam"), icon: "group", run: () => emit("join-team") }
+ : null;
+});
+
/// The open pages, one row per page with its newest send.
const openRows = computed
(() => {
const byPage = new Map();
@@ -415,8 +439,10 @@ const openRows = computed(() => {
const historyRows = computed(() => {
const groups = new Map();
for (const row of rows.value) {
- // The id carries the firing ("…#2"); without it every firing of one alert is its own row.
- const key = `${row.subject_type}:${row.subject_id.replace(/#\d+$/, "")}`;
+ // By name: an alert and the incident it opened page under one title, and the reader cannot tell them apart.
+ const key = row.title
+ ? `title:${row.title}`
+ : `${row.subject_type}:${row.subject_id.replace(/#\d+$/, "")}`;
groups.set(key, [...(groups.get(key) ?? []), row]);
}
return [...groups.entries()]
@@ -463,10 +489,8 @@ const openColumns = computed[]>(() => [
]);
const historyColumns = computed[]>(() => [
- // Only while something is unread: a column of empty dots says nothing.
- ...(historyRows.value.some((row) => row.unread)
- ? [{ id: "unread", header: raw(""), size: 32, sortable: false }]
- : []),
+ // Always present: a column that arrived with the first unread row shifted every other column.
+ { id: "unread", header: raw(""), size: 32, sortable: false },
{
id: "page",
header: t("oncall.myDeliveriesPage"),
@@ -479,14 +503,13 @@ const historyColumns = computed[]>(() => [
{ id: "state", header: t("oncall.myDeliveriesStatus"), size: 128, sortable: false },
{ id: "channels", header: t("oncall.myDeliveriesChannels"), size: 104, sortable: false },
{ id: "at", header: t("oncall.myDeliveriesWhen"), size: 176, sortable: false },
+ // Not `isAction`: that pins the column, and its divider reads as a stray border on a table this narrow.
{
- id: "actions",
+ id: "row-actions",
header: t("oncall.actions"),
- isAction: true,
- pinned: "right",
sortable: false,
size: 64,
- meta: { align: "center", actionCount: 1 },
+ meta: { align: "center" },
},
]);
diff --git a/web/src/components/oncall/OnCallRoutingSimulator.spec.ts b/web/src/components/oncall/OnCallRoutingSimulator.spec.ts
index 42c5a58535..6c9b96f390 100644
--- a/web/src/components/oncall/OnCallRoutingSimulator.spec.ts
+++ b/web/src/components/oncall/OnCallRoutingSimulator.spec.ts
@@ -284,6 +284,7 @@ describe("OnCallRoutingSimulator", () => {
expect(next).toBe("then Kim after 5m");
});
+ /// The server's reason can run to a sentence; it sits in the tooltip so the answer stays on one line.
it("says why when nobody on the first step can receive a page", async () => {
vi.useRealTimers();
escalationPreview.mockResolvedValue({
@@ -311,7 +312,8 @@ describe("OnCallRoutingSimulator", () => {
await flushPromises();
await flushPromises();
const responder = wrapper.find('[data-test="oncall-simulator-responder"]');
- expect(responder.text()).toBe("no verified channel");
+ expect(responder.text()).toBe("No channel can carry a page");
+ expect(responder.findComponent(OTooltip).props("content")).toBe("no verified channel");
expect(responder.classes()).toContain("text-status-error-text");
});
diff --git a/web/src/components/oncall/OnCallRoutingSimulator.vue b/web/src/components/oncall/OnCallRoutingSimulator.vue
index d8a8a9182a..af6f2767c8 100644
--- a/web/src/components/oncall/OnCallRoutingSimulator.vue
+++ b/web/src/components/oncall/OnCallRoutingSimulator.vue
@@ -104,6 +104,12 @@ along with this program. If not, see .
{{ responderNote }}
+
{{ raw(" · ") }}
@@ -537,18 +543,27 @@ const responderNote = computed(() => {
const step = firstStep.value;
if (step) {
if (!step.recipients.length) return t("oncall.simulatorNobodyOnCall");
- const blocked = step.recipients.find((one) => !one.would_a_page_land);
- if (!step.recipients.some((one) => one.would_a_page_land)) {
- return raw(blocked?.why_not) || t("oncall.contactNoChannel");
- }
+ if (!step.recipients.some((one) => one.would_a_page_land)) return t("oncall.contactNoChannel");
return t("oncall.simulatorSendsNothing");
}
const person = responder.value;
if (!person) return t("oncall.simulatorNobodyOnCall");
- if (!person.would_a_page_land) return raw(person.why_not) || t("oncall.contactNoChannel");
+ if (!person.would_a_page_land) return t("oncall.contactNoChannel");
return t("oncall.simulatorSendsNothing");
});
+/// The server's reason a page cannot land runs to a sentence, so it rides the tooltip and the answer stays one line.
+const responderWhy = computed(() => {
+ if (ladderEntry.value && !ladderEntry.value.pages_anyone) return null;
+ const step = firstStep.value;
+ if (step) {
+ if (step.recipients.some((one) => one.would_a_page_land)) return null;
+ return raw(step.recipients.find((one) => !one.would_a_page_land)?.why_not) || null;
+ }
+ const person = responder.value;
+ return person && !person.would_a_page_land ? raw(person.why_not) || null : null;
+});
+
const responderTone = computed(() => {
if (ladderEntry.value && !ladderEntry.value.pages_anyone) return "text-text-secondary";
if (firstStep.value) {
diff --git a/web/src/components/oncall/OnCallSetupBanner.spec.ts b/web/src/components/oncall/OnCallSetupBanner.spec.ts
index 5e0d055f13..3c0e50a414 100644
--- a/web/src/components/oncall/OnCallSetupBanner.spec.ts
+++ b/web/src/components/oncall/OnCallSetupBanner.spec.ts
@@ -107,6 +107,31 @@ describe("OnCallSetupBanner", () => {
expect(wrapper.find('[data-test="oncall-setup-banner-host"]').exists()).toBe(false);
});
+ /// One answering policy settles the step, so an org with many teams costs one policy read, not one per team.
+ it("stops reading policies at the first team that has one", async () => {
+ answer({
+ teams: [
+ { id: "team_1", name: "A" },
+ { id: "team_2", name: "B" },
+ { id: "team_3", name: "C" },
+ ],
+ });
+ await render();
+ expect(service.getPolicy).toHaveBeenCalledTimes(1);
+ });
+
+ /// A dismissed bar never shows, so it asks the server nothing.
+ it("reads nothing once dismissed", async () => {
+ answer();
+ const wrapper = await render();
+ await wrapper.find('[data-test="oncall-setup-dismiss"]').trigger("click");
+ vi.clearAllMocks();
+ queryClient.clear();
+ await render();
+ expect(service.listTeams).not.toHaveBeenCalled();
+ expect(service.getPolicy).not.toHaveBeenCalled();
+ });
+
/// A read that failed is not a step left undone, so the bar stays away.
it("stays away when a read fails", async () => {
answer();
diff --git a/web/src/components/oncall/OnCallSetupBanner.vue b/web/src/components/oncall/OnCallSetupBanner.vue
index b1e1cdb326..1961e56d5a 100644
--- a/web/src/components/oncall/OnCallSetupBanner.vue
+++ b/web/src/components/oncall/OnCallSetupBanner.vue
@@ -106,9 +106,21 @@ async function read(
return queryClient.fetchQuery(options as any) as Promise;
}
+/// One answering policy is enough, so teams are asked in turn rather than all at once.
+async function anyPolicy(org: string, teamList: OnCallTeam[], force: boolean) {
+ for (const team of teamList) {
+ const policy = await read(teamPolicyQuery(org, team.id), force).catch(
+ () => null,
+ );
+ if (policy) return true;
+ }
+ return false;
+}
+
async function load(force = false) {
const org = orgId.value;
- if (!org) return;
+ // A dismissed bar never shows, so its facts are not worth a request.
+ if (!org || dismissed.value) return;
try {
const [teamList, gaps, rules, pages] = await Promise.all([
read(oncallTeamsQuery(org), force),
@@ -116,15 +128,13 @@ async function load(force = false) {
read(ownershipRulesQuery(org), force),
read(responsesQuery(org, { include_resolved: true, limit: 20 }), force),
]);
- const policies = await Promise.allSettled(
- teamList.map((team) => read(teamPolicyQuery(org, team.id), force)),
- );
+ const hasPolicy = await anyPolicy(org, teamList, force);
teams.value = teamList;
facts.value = {
hasTeam: teamList.length > 0,
hasStaffedRotation: teamList.length > 0 && (gaps?.total ?? 0) < teamList.length,
// The server creates the default ladder on first read, so a policy that answers is one that pages.
- hasPolicy: policies.some((p) => p.status === "fulfilled" && !!p.value),
+ hasPolicy,
// An alert bound straight to a team counts: it is routing without a rule.
hasRouting: rules.length > 0 || pages.some((page) => !!page.team_id),
};
diff --git a/web/src/components/settings/SlackConnectEmpty.spec.ts b/web/src/components/settings/SlackConnectEmpty.spec.ts
index 7e68396f2e..bf97c3f8ee 100644
--- a/web/src/components/settings/SlackConnectEmpty.spec.ts
+++ b/web/src/components/settings/SlackConnectEmpty.spec.ts
@@ -94,15 +94,12 @@ describe("SlackConnectEmpty on Cloud (A1)", () => {
const w = mountEmpty();
const text = w.text();
expect(text).toContain("Connect a Slack workspace");
- expect(text).toContain(
- "Your team asks OpenObserve questions from Slack and gets answers with their own permissions.",
- );
- expect(text).not.toContain("creates and installs your own Slack app");
- expect(text).toContain("What happens next");
+ expect(text).not.toContain("Your team asks OpenObserve questions from Slack");
+ expect(text).not.toContain("What happens next");
const steps = w.findAll('[data-test="slack-connect-step"]').map((s) => s.text());
expect(steps).toHaveLength(2);
expect(steps[0]).toContain("Add to Slack");
- expect(steps[0]).toContain("Opens Slack in a popup: pick a channel for alerts, then Allow");
+ expect(steps[0]).not.toContain("Opens Slack in a popup");
expect(steps[1]).toContain("Link people");
expect(text).not.toMatch(/Acknowledge|unfurl|Phase/);
// A privacy fact rides behind one info icon, not a banner under the steps.
@@ -284,13 +281,11 @@ describe("SlackConnectEmpty self-hosted (A1)", () => {
const w = mountEmpty({ isCloud: false });
const text = w.text();
expect(text).not.toContain("nothing is shared with a third party");
- expect(text).toContain("What happens next · about 5 minutes");
+ expect(text).not.toContain("What happens next");
const steps = w.findAll('[data-test="slack-connect-step"]').map((s) => s.text());
expect(steps).toHaveLength(3);
expect(steps[0]).toContain("Check this server");
- expect(steps[0]).toContain("Encryption key, web URL and AI agent");
expect(steps[1]).toContain("Create the app");
- expect(steps[1]).toContain("Paste one configuration token from Slack");
expect(steps[2]).toContain("Install and link people");
expect(text).not.toContain("Ask questions; answers use your own permissions");
expect(text).toContain("Needs the Manage Slack permission");
@@ -298,15 +293,12 @@ describe("SlackConnectEmpty self-hosted (A1)", () => {
expect(w.find('[data-test="slack-connect-steps"]').attributes("role")).toBe("list");
});
- /// One short line per step at every width: the long details made the empty state a wall of text.
- it("keeps every step detail to one short line", () => {
+ /// A step is its title alone: the sub-lines under each made the empty state a wall of text.
+ it("shows each step as its title only", () => {
const steps = mountEmpty({ isCloud: false })
.findAll('[data-test="slack-connect-step"]')
.map((s) => s.text());
- expect(steps[0]).toContain("Encryption key, web URL and AI agent");
- expect(steps[0]).not.toContain("verified before anything touches Slack");
- expect(steps[1]).not.toContain("builds the app for you");
- expect(steps[2]).toContain("Allow it in Slack, then link members");
+ expect(steps).toEqual(["1Check this server", "2Create the app", "3Install and link people"]);
});
it("offers Connect this org to an existing workspace before the setup wizard", () => {
diff --git a/web/src/components/settings/SlackConnectEmpty.vue b/web/src/components/settings/SlackConnectEmpty.vue
index 6cb3da493e..c8679dc942 100644
--- a/web/src/components/settings/SlackConnectEmpty.vue
+++ b/web/src/components/settings/SlackConnectEmpty.vue
@@ -163,28 +163,13 @@ const connectWithTicket = async (): Promise => {
const candidate = computed(() => props.upgradeCandidates[0] ?? null);
const addToSlackSrcset = `${addToSlackImage2x} 2x`;
-const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
+const nextSteps = computed(() =>
props.isCloud
- ? [
- { title: t("slackApp.connect.stepAddTitle"), detail: t("slackApp.connect.popupHint") },
- {
- title: t("slackApp.connect.stepLinkTitle"),
- detail: t("slackApp.connect.stepLinkDetail"),
- },
- ]
+ ? [t("slackApp.connect.stepAddTitle"), t("slackApp.connect.stepLinkTitle")]
: [
- {
- title: t("slackApp.connect.stepCheckTitle"),
- detail: t("slackApp.connect.stepCheckDetailShort"),
- },
- {
- title: t("slackApp.connect.stepCreateTitle"),
- detail: t("slackApp.connect.stepCreateDetailShort"),
- },
- {
- title: t("slackApp.connect.stepInstallTitle"),
- detail: t("slackApp.connect.stepInstallDetailShort"),
- },
+ t("slackApp.connect.stepCheckTitle"),
+ t("slackApp.connect.stepCreateTitle"),
+ t("slackApp.connect.stepInstallTitle"),
],
);
@@ -290,14 +275,9 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
class="gap-3 p-5"
data-test="slack-available"
>
-
-
- {{ t("slackApp.connect.existingTitle") }}
-
-
- {{ t("slackApp.connect.existingDescriptionShort") }}
-
-
+
+ {{ t("slackApp.connect.existingTitle") }}
+
(() =>
size="block"
illustration="connect"
:title="t('slackApp.connect.title')"
- :description="t('slackApp.connect.description')"
:backdrop="false"
>
@@ -355,9 +334,6 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
-
- {{ t("slackApp.connect.nextTitle", { minutes: isCloud ? 2 : 5 }) }}
-
(() =>
-
(() =>
>
{{ index + 1 }}
-
- {{ item.title }}
- {{ item.detail }}
-
+ {{ item }}
diff --git a/web/src/components/settings/SlackLinkingSection.spec.ts b/web/src/components/settings/SlackLinkingSection.spec.ts
index 41f91b0d71..46a5488285 100644
--- a/web/src/components/settings/SlackLinkingSection.spec.ts
+++ b/web/src/components/settings/SlackLinkingSection.spec.ts
@@ -85,7 +85,7 @@ describe("SlackLinkingSection (row 11)", () => {
it("AC-12.1 manual: edits domains and refuses a public email domain", async () => {
const w = await mountSection(linkingView({ source: "manual", domains: ["acme.example"] }));
- expect(w.find('[data-test="slack-linking-summary"]').text()).toBe("Automatic for acme.example");
+ expect(w.find('[data-test="slack-linking-summary"]').exists()).toBe(false);
await w.find('[data-test="slack-linking-edit"]').trigger("click");
const input = w.find('[data-test="slack-linking-domains"] [data-test="tag-input-field"]');
await input.setValue("gmail.com");
@@ -164,7 +164,7 @@ describe("SlackLinkingSection (row 11)", () => {
const w = await mountSection(linkingView({ domains: c.domains, counts }));
const warning = w.find('[data-test="slack-linking-no-domains"]');
expect(warning.exists() ? warning.text().replace(/\s+/g, " ") : null).toBe(c.warning);
- expect(w.find('[data-test="slack-linking-summary"]').exists()).toBe(!c.warning);
+ expect(w.find('[data-test="slack-linking-summary"]').exists()).toBe(false);
expect(w.find('[data-test="slack-linking-edit"]').exists()).toBe(true);
});
@@ -198,7 +198,7 @@ describe("SlackLinkingSection (row 11)", () => {
await error.find("button").trigger("click");
await flushPromises();
expect(slack.getLinking).toHaveBeenCalledTimes(2);
- expect(w.find('[data-test="slack-linking-summary"]').exists()).toBe(true);
+ expect(w.find('[data-test="slack-linking-counts"]').exists()).toBe(true);
});
it("a managed workspace shows linking read-only, set by your instance admin", async () => {
diff --git a/web/src/components/settings/SlackLinkingSection.vue b/web/src/components/settings/SlackLinkingSection.vue
index 4c62428cec..88f0887cbf 100644
--- a/web/src/components/settings/SlackLinkingSection.vue
+++ b/web/src/components/settings/SlackLinkingSection.vue
@@ -60,9 +60,10 @@ const summary = computed(() => {
const current = view.value;
if (!current) return "";
if (!current.enabled) return t("slackApp.linking.offSummary");
+ // Typed-in domains are one Edit domains click away; only a mapping's domains need saying, since they cannot be edited here.
return current.source === "mapping"
? t("slackApp.linking.mappedSummary", { domains: domainsText.value })
- : t("slackApp.linking.manualSummary", { domains: domainsText.value });
+ : "";
});
const noDomains = computed(() => !!view.value?.enabled && !view.value.domains.length);
const autoCount = computed(() =>
@@ -175,7 +176,11 @@ const runLinkAll = async (): Promise => {
}}
-
+
{{ summary }}
@@ -256,14 +261,9 @@ const runLinkAll = async (): Promise => {
-
-
- {{ t("slackApp.linking.appHome") }}
-
-
- {{ t("slackApp.linking.appHomeDescription") }}
-
-
+
+ {{ t("slackApp.linking.appHome") }}
+
diff --git a/web/src/components/settings/SlackWorkspaceCard.vue b/web/src/components/settings/SlackWorkspaceCard.vue
index 3b130f4bcc..b315bc9021 100644
--- a/web/src/components/settings/SlackWorkspaceCard.vue
+++ b/web/src/components/settings/SlackWorkspaceCard.vue
@@ -597,7 +597,6 @@ const saveHelpContact = async (value: HelpContactForm): Promise => {
class="min-w-0 flex-1 md:max-w-120"
:label="t('slackApp.card.helpContact')"
:placeholder="t('slackApp.card.helpContactPlaceholder')"
- :help-text="t('slackApp.card.helpContactHelp')"
:maxlength="HELP_CONTACT_MAX_LENGTH"
data-test="slack-help-contact"
/>
diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue
index c8c9c1ea26..30d230951e 100644
--- a/web/src/components/settings/index.vue
+++ b/web/src/components/settings/index.vue
@@ -37,7 +37,7 @@ along with this program. If not, see .
class="border-border-default shrink-0 border-b"
/>
CONSTRAINED_SECTIONS.has(activeSection.value));
+ // Card-and-table sections use the row width, like Telephony and the destination lists.
+ const FULL_WIDTH_SECTIONS = new Set(["password_policy", "slack"]);
const slackVisible = computed(() => {
const edition = buildFeatureGateContext(store.state.zoConfig);
@@ -546,6 +548,7 @@ export default defineComponent({
isMetaOrg,
activeSection,
isConstrainedSection,
+ FULL_WIDTH_SECTIONS,
activeSectionItem,
sectionGroups,
handleSettingsRouting,
diff --git a/web/src/lib/core/Navbar/navGroups.spec.ts b/web/src/lib/core/Navbar/navGroups.spec.ts
index d92d0c2a4e..47ae946be3 100644
--- a/web/src/lib/core/Navbar/navGroups.spec.ts
+++ b/web/src/lib/core/Navbar/navGroups.spec.ts
@@ -868,10 +868,17 @@ describe("GATE_PREDICATES", () => {
expect(GATE_PREDICATES.downtimes(ctx({ isEnterprise: true }))).toBe(false);
});
- it("marks Downtimes and Alert Calibration as Beta in the Reliability flyout, and nothing else there", () => {
+ it("marks Downtimes, Alert Calibration and every On-Call entry as Beta in the Reliability flyout, and nothing else there", () => {
const reliability = NAV_GROUPS.find((g) => g.children.some((c) => c.name === "downtimes"))!;
const beta = reliability.children.filter((c) => c.beta).map((c) => c.name);
- expect(beta).toEqual(["downtimes", "alertCalibration"]);
+ expect(beta).toEqual([
+ "downtimes",
+ "alertCalibration",
+ "onCallResponses",
+ "onCallTeams",
+ "onCallRouting",
+ "onCallMine",
+ ]);
});
it("puts Downtimes after Incidents when the rail carries a downtimes item", () => {
diff --git a/web/src/lib/core/Navbar/navGroups.ts b/web/src/lib/core/Navbar/navGroups.ts
index 6da6dc0611..8ca4300055 100644
--- a/web/src/lib/core/Navbar/navGroups.ts
+++ b/web/src/lib/core/Navbar/navGroups.ts
@@ -237,6 +237,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
name: "onCallResponses",
categoryKey: "menu.onCall",
gate: "oncall",
+ beta: true,
},
{
titleKey: "oncall.teams",
@@ -244,6 +245,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
name: "onCallTeams",
categoryKey: "menu.onCall",
gate: "oncall",
+ beta: true,
},
{
titleKey: "oncall.routingNav",
@@ -251,6 +253,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
name: "onCallRouting",
categoryKey: "menu.onCall",
gate: "oncall",
+ beta: true,
},
// Same icon as the page's own header, so the menu and the page read as one place.
{
@@ -259,6 +262,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
name: "onCallMine",
categoryKey: "menu.onCall",
gate: "oncall",
+ beta: true,
},
// Where external alerts (Grafana, Alertmanager, etc.) feed Incidents.
// Gated on incidentList, not alertList: this only makes sense where
diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json
index db4fa2175b..ddab4aef31 100644
--- a/web/src/locales/languages/ar-SA.json
+++ b/web/src/locales/languages/ar-SA.json
@@ -25526,7 +25526,8 @@
"subjectStream": "التدفق",
"firingHistory": "آخر الإطلاقات",
"firingHistoryHint": "كل إطلاق سابق لهذا الموضوع نفسه، الأحدث أولاً. عدد مرات إطلاقه وما إذا كان أحد قد استجاب هو ما يفصل المشكلة الحقيقية عن عتبة تحتاج إلى ضبط.",
- "firingHistoryEmpty": "هذه هي المرة الأولى التي يستدعي فيها هذا الموضوع أي شخص.",
+ "firingHistoryNone": "لا توجد عمليات إطلاق سابقة",
+ "mineJoinTeam": "الانضمام إلى فريق",
"neverAcknowledged": "لم يتم الإقرار به أبدًا.",
"age": "العمر",
"openedAt": "وقت الفتح",
@@ -25581,6 +25582,8 @@
"unacknowledged": "تم إلغاء الإقرار · يُعاد بدء الاستدعاء لـ{team}",
"unacknowledgedNoTeam": "تم إلغاء الإقرار · يُعاد بدء الاستدعاء",
"unacknowledgeFailed": "تعذر إلغاء الإقرار بالنداء",
+ "unackAlertDeleted": "لا يمكن إلغاء الإقرار: تم حذف تنبيه هذه الصفحة",
+ "unackRefused": "تعذر إلغاء الإقرار بهذه الصفحة",
"takeOver": "تولَّ المسؤولية",
"takeOverHint": "ينقل الإقرار إليك",
"tookOver": "تولّيت المسؤولية من {who}",
diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json
index 6a7ff0e172..2a29823219 100644
--- a/web/src/locales/languages/de-DE.json
+++ b/web/src/locales/languages/de-DE.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Letzte Auslösungen",
"firingHistoryHint": "Jedes frühere Auslösen desselben Betreffs, neueste zuerst. Wie oft es auslöst und ob jemand geantwortet hat, unterscheidet ein echtes Problem von einem Schwellenwert, der angepasst werden muss.",
- "firingHistoryEmpty": "Dies ist das erste Mal, dass dieser Betreff jemanden alarmiert hat.",
+ "firingHistoryNone": "Keine früheren Auslösungen",
+ "mineJoinTeam": "Einem Team beitreten",
"neverAcknowledged": "Nie bestätigt",
"age": "Alter",
"openedAt": "Geöffnet",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Bestätigung aufgehoben · Alarmierung für {team} wird neu gestartet",
"unacknowledgedNoTeam": "Bestätigung aufgehoben · Alarmierung wird neu gestartet",
"unacknowledgeFailed": "Bestätigung der Alarmierung konnte nicht aufgehoben werden",
+ "unackAlertDeleted": "Bestätigung kann nicht aufgehoben werden: Der Alarm dieser Seite wurde gelöscht",
+ "unackRefused": "Bestätigung dieser Seite konnte nicht aufgehoben werden",
"takeOver": "Übernehmen",
"takeOverHint": "Überträgt die Bestätigung auf Sie",
"tookOver": "Sie haben von {who} übernommen",
diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json
index 66f710c79f..0b906e35c4 100644
--- a/web/src/locales/languages/en-US.json
+++ b/web/src/locales/languages/en-US.json
@@ -25614,7 +25614,7 @@
"subjectStream": "Stream",
"firingHistory": "Recent firings",
"firingHistoryHint": "Every earlier firing of this same subject, newest first. How often it fires and whether anybody answered is what separates a real problem from a threshold that needs tuning.",
- "firingHistoryEmpty": "This is the first time this subject has paged anybody.",
+ "firingHistoryNone": "No earlier firings",
"neverAcknowledged": "Never acknowledged",
"age": "Age",
"openedAt": "Opened",
@@ -25669,6 +25669,8 @@
"unacknowledged": "Unacknowledged · paging restarts for {team}",
"unacknowledgedNoTeam": "Unacknowledged · paging restarts",
"unacknowledgeFailed": "Could not unacknowledge the page",
+ "unackAlertDeleted": "Can't unacknowledge: this page's alert was deleted",
+ "unackRefused": "Couldn't unacknowledge this page",
"takeOver": "Take over",
"takeOverHint": "Moves the acknowledgement to you",
"tookOver": "You took over from {who}",
@@ -26017,7 +26019,6 @@
"mineTitle": "My on-call",
"mineSubtitle": "Your shifts, how pages reach you, and what has been sent to you",
"mineShiftsTitle": "Your shifts",
- "rowOpenPage": "Open page",
"rowShowTimeline": "Show timeline",
"rowHideTimeline": "Hide timeline",
"rowCopyLink": "Copy link",
@@ -26031,6 +26032,7 @@
"mineShiftsMore": "{count} more | {count} more",
"mineShiftLeft": "{left} left",
"mineOpenTeams": "Open teams",
+ "mineJoinTeam": "Join a team",
"mineTestTeam": "Send a test page to {team}",
"mineCoverSaved": "Cover saved",
"mineSlackLinkHow": "Type /o2 link in Slack",
diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json
index 3a19628b1f..18984a2b24 100644
--- a/web/src/locales/languages/es-ES.json
+++ b/web/src/locales/languages/es-ES.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Disparos recientes",
"firingHistoryHint": "Cada disparo anterior de este mismo asunto, del más reciente al más antiguo. La frecuencia con la que se dispara y si alguien respondió es lo que separa un problema real de un umbral que necesita ajuste.",
- "firingHistoryEmpty": "Esta es la primera vez que este asunto ha avisado a alguien.",
+ "firingHistoryNone": "Sin activaciones anteriores",
+ "mineJoinTeam": "Unirse a un equipo",
"neverAcknowledged": "Nunca reconocido",
"age": "Antigüedad",
"openedAt": "Abierto",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Sin confirmar · se reinicia el aviso para {team}",
"unacknowledgedNoTeam": "Sin confirmar · se reinicia el aviso",
"unacknowledgeFailed": "No se pudo anular la confirmación del aviso",
+ "unackAlertDeleted": "No se puede anular el reconocimiento: la alerta de esta página se eliminó",
+ "unackRefused": "No se pudo anular el reconocimiento de esta página",
"takeOver": "Hacerse cargo",
"takeOverHint": "Traslada la confirmación a tu nombre",
"tookOver": "Has tomado el relevo de {who}",
diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json
index 901e6aabe9..4b47360518 100644
--- a/web/src/locales/languages/fr-FR.json
+++ b/web/src/locales/languages/fr-FR.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Flux",
"firingHistory": "Déclenchements récents",
"firingHistoryHint": "Chaque déclenchement antérieur de ce sujet, du plus récent au plus ancien. La fréquence à laquelle il se déclenche et le fait que quelqu'un ait répondu font la différence entre un vrai problème et un seuil à régler.",
- "firingHistoryEmpty": "C'est la première fois que ce sujet a envoyé une notification à quelqu'un.",
+ "firingHistoryNone": "Aucun déclenchement antérieur",
+ "mineJoinTeam": "Rejoindre une équipe",
"neverAcknowledged": "Jamais pris en charge",
"age": "Âge",
"openedAt": "Ouvert",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Prise en charge annulée · notifications relancées pour {team}",
"unacknowledgedNoTeam": "Prise en charge annulée · notifications relancées",
"unacknowledgeFailed": "Impossible d'annuler la prise en charge de la notification",
+ "unackAlertDeleted": "Impossible d'annuler la prise en charge : l'alerte de cette page a été supprimée",
+ "unackRefused": "Impossible d'annuler la prise en charge de cette page",
"takeOver": "Prendre le relais",
"takeOverHint": "Vous transfère la prise en charge",
"tookOver": "Vous avez pris le relais de {who}",
diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json
index 952233665d..810822188e 100644
--- a/web/src/locales/languages/it-IT.json
+++ b/web/src/locales/languages/it-IT.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Attivazioni recenti",
"firingHistoryHint": "Tutte le precedenti attivazioni di questo stesso soggetto, dalla più recente. La frequenza con cui si attiva e se qualcuno ha risposto è ciò che distingue un problema reale da una soglia che necessita di regolazione.",
- "firingHistoryEmpty": "Questa è la prima volta che questo soggetto invia una notifica a qualcuno.",
+ "firingHistoryNone": "Nessuna attivazione precedente",
+ "mineJoinTeam": "Unisciti a un team",
"neverAcknowledged": "Mai presa in carico",
"age": "Età",
"openedAt": "Aperto",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Presa in carico annullata · notifiche riavviate per {team}",
"unacknowledgedNoTeam": "Presa in carico annullata · notifiche riavviate",
"unacknowledgeFailed": "Impossibile annullare la presa in carico della notifica",
+ "unackAlertDeleted": "Impossibile annullare la presa in carico: l'avviso di questa pagina è stato eliminato",
+ "unackRefused": "Impossibile annullare la presa in carico di questa pagina",
"takeOver": "Subentra",
"takeOverHint": "Sposta la presa in carico su di te",
"tookOver": "Hai preso in carico al posto di {who}",
diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json
index 8514777cbf..cbc64d8a33 100644
--- a/web/src/locales/languages/ja-JP.json
+++ b/web/src/locales/languages/ja-JP.json
@@ -25526,7 +25526,8 @@
"subjectStream": "ストリーム",
"firingHistory": "最近の発火",
"firingHistoryHint": "同じサブジェクトの過去の各発火を新しい順に表示します。発火頻度と、誰かが対応したかどうかが、実際の問題と調整が必要なしきい値を見分ける手がかりになります。",
- "firingHistoryEmpty": "このサブジェクトが誰かを呼び出したのは今回が初めてです。",
+ "firingHistoryNone": "以前の発火はありません",
+ "mineJoinTeam": "チームに参加",
"neverAcknowledged": "一度も対応されていません",
"age": "経過時間",
"openedAt": "発生日時",
@@ -25581,6 +25582,8 @@
"unacknowledged": "確認応答を取り消しました · {team} への呼び出しを再開します",
"unacknowledgedNoTeam": "確認応答を取り消しました · 呼び出しを再開します",
"unacknowledgeFailed": "呼び出しの確認応答を取り消せませんでした",
+ "unackAlertDeleted": "確認を取り消せません: このページのアラートは削除されています",
+ "unackRefused": "このページの確認を取り消せませんでした",
"takeOver": "引き継ぐ",
"takeOverHint": "確認応答をあなたに移します",
"tookOver": "{who} から引き継ぎました",
diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json
index f43435fcbe..5af06906b9 100644
--- a/web/src/locales/languages/ko-KR.json
+++ b/web/src/locales/languages/ko-KR.json
@@ -25526,7 +25526,8 @@
"subjectStream": "스트림",
"firingHistory": "최근 발생 내역",
"firingHistoryHint": "이 동일한 주제의 이전 발생 항목이 최신순으로 표시됩니다. 발생 빈도와 누군가 응답했는지 여부가 실제 문제와 조정이 필요한 임계값을 구분합니다.",
- "firingHistoryEmpty": "이 주제가 누군가를 호출한 것은 이번이 처음입니다.",
+ "firingHistoryNone": "이전 발생 없음",
+ "mineJoinTeam": "팀에 참여",
"neverAcknowledged": "확인된 적 없음",
"age": "경과 시간",
"openedAt": "발생 시각",
@@ -25581,6 +25582,8 @@
"unacknowledged": "확인 취소됨 · {team} 호출 재시작됨",
"unacknowledgedNoTeam": "확인 취소됨 · 호출 재시작됨",
"unacknowledgeFailed": "호출 확인을 취소할 수 없습니다",
+ "unackAlertDeleted": "확인을 취소할 수 없습니다: 이 페이지의 알림이 삭제되었습니다",
+ "unackRefused": "이 페이지의 확인을 취소하지 못했습니다",
"takeOver": "넘겨받기",
"takeOverHint": "확인을 내게로 가져옵니다",
"tookOver": "{who}에게서 넘겨받았습니다",
diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json
index 5cfeb8385b..62d5619003 100644
--- a/web/src/locales/languages/nl-NL.json
+++ b/web/src/locales/languages/nl-NL.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Recente activaties",
"firingHistoryHint": "Elke eerdere keer dat ditzelfde alarm afging, nieuwste eerst. Hoe vaak het afgaat en of iemand heeft gereageerd, is wat een echt probleem onderscheidt van een drempelwaarde die moet worden bijgesteld.",
- "firingHistoryEmpty": "Dit is de eerste keer dat dit alarm iemand heeft opgeroepen.",
+ "firingHistoryNone": "Geen eerdere activeringen",
+ "mineJoinTeam": "Lid worden van een team",
"neverAcknowledged": "Nooit bevestigd",
"age": "Leeftijd",
"openedAt": "Geopend",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Bevestiging ingetrokken · oproepen naar {team} herstart",
"unacknowledgedNoTeam": "Bevestiging ingetrokken · oproepen herstart",
"unacknowledgeFailed": "Kon de bevestiging van de oproep niet intrekken",
+ "unackAlertDeleted": "Bevestiging kan niet ongedaan worden gemaakt: de alert van deze pagina is verwijderd",
+ "unackRefused": "Bevestiging van deze pagina kon niet ongedaan worden gemaakt",
"takeOver": "Overnemen",
"takeOverHint": "Verplaatst de bevestiging naar jou",
"tookOver": "Je hebt overgenomen van {who}",
diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json
index 39af16d236..b8274d7956 100644
--- a/web/src/locales/languages/pl-PL.json
+++ b/web/src/locales/languages/pl-PL.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Strumień",
"firingHistory": "Ostatnie wyzwolenia",
"firingHistoryHint": "Każde wcześniejsze wyzwolenie tego samego obiektu, od najnowszego. To, jak często się wyzwala i czy ktokolwiek zareagował, odróżnia prawdziwy problem od progu, który wymaga dostrojenia.",
- "firingHistoryEmpty": "To pierwszy raz, kiedy ten obiekt kogokolwiek wezwał.",
+ "firingHistoryNone": "Brak wcześniejszych wyzwoleń",
+ "mineJoinTeam": "Dołącz do zespołu",
"neverAcknowledged": "Nigdy niepotwierdzone",
"age": "Wiek",
"openedAt": "Otwarto",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Cofnięto potwierdzenie · wzywanie dla {team} zostaje wznowione",
"unacknowledgedNoTeam": "Cofnięto potwierdzenie · wzywanie zostaje wznowione",
"unacknowledgeFailed": "Nie udało się cofnąć potwierdzenia wezwania",
+ "unackAlertDeleted": "Nie można cofnąć potwierdzenia: alert tej strony został usunięty",
+ "unackRefused": "Nie udało się cofnąć potwierdzenia tej strony",
"takeOver": "Przejmij",
"takeOverHint": "Przenosi potwierdzenie na Ciebie",
"tookOver": "Przejąłeś od {who}",
diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json
index 784158ec25..46ed660df9 100644
--- a/web/src/locales/languages/pt-PT.json
+++ b/web/src/locales/languages/pt-PT.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Disparos recentes",
"firingHistoryHint": "Cada disparo anterior deste mesmo assunto, do mais recente para o mais antigo. A frequência com que dispara e se alguém respondeu é o que separa um problema real de um limite que precisa de ajuste.",
- "firingHistoryEmpty": "Esta é a primeira vez que este assunto acionou alguém.",
+ "firingHistoryNone": "Sem disparos anteriores",
+ "mineJoinTeam": "Juntar-se a uma equipa",
"neverAcknowledged": "Nunca reconhecido",
"age": "Idade",
"openedAt": "Aberto",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Assunção anulada · acionamento reiniciado para {team}",
"unacknowledgedNoTeam": "Assunção anulada · acionamento reiniciado",
"unacknowledgeFailed": "Não foi possível anular a assunção do acionamento",
+ "unackAlertDeleted": "Não é possível anular o reconhecimento: o alerta desta página foi eliminado",
+ "unackRefused": "Não foi possível anular o reconhecimento desta página",
"takeOver": "Assumir controlo",
"takeOverHint": "Transfere a assunção para si",
"tookOver": "Assumiu o controlo de {who}",
diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json
index a99b8a13a4..05b55e8b8e 100644
--- a/web/src/locales/languages/ru-RU.json
+++ b/web/src/locales/languages/ru-RU.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Поток",
"firingHistory": "Недавние срабатывания",
"firingHistoryHint": "Все предыдущие срабатывания этого же объекта, сначала новые. Как часто это срабатывает и ответил ли кто-нибудь — это отличает реальную проблему от порога, который нужно настроить.",
- "firingHistoryEmpty": "Этот объект впервые кого-либо вызвал.",
+ "firingHistoryNone": "Более ранних срабатываний нет",
+ "mineJoinTeam": "Присоединиться к команде",
"neverAcknowledged": "Никогда не подтверждено",
"age": "Возраст",
"openedAt": "Открыто",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Подтверждение отменено · вызов {team} перезапускается",
"unacknowledgedNoTeam": "Подтверждение отменено · вызов перезапускается",
"unacknowledgeFailed": "Не удалось отменить подтверждение вызова",
+ "unackAlertDeleted": "Невозможно отменить подтверждение: оповещение этой страницы удалено",
+ "unackRefused": "Не удалось отменить подтверждение этой страницы",
"takeOver": "Перехватить",
"takeOverHint": "Передаёт подтверждение вам",
"tookOver": "Вы перехватили у {who}",
diff --git a/web/src/locales/languages/th-TH.json b/web/src/locales/languages/th-TH.json
index 5d1b88a770..1e9c8ad907 100644
--- a/web/src/locales/languages/th-TH.json
+++ b/web/src/locales/languages/th-TH.json
@@ -25524,7 +25524,8 @@
"subjectStream": "Stream",
"firingHistory": "Recent firings",
"firingHistoryHint": "Every earlier firing of this same subject, newest first. How often it fires and whether anybody answered is what separates a real problem from a threshold that needs tuning.",
- "firingHistoryEmpty": "This is the first time this subject has paged anybody.",
+ "firingHistoryNone": "ไม่มีการทำงานก่อนหน้านี้",
+ "mineJoinTeam": "เข้าร่วมทีม",
"neverAcknowledged": "Never acknowledged",
"age": "Age",
"openedAt": "Opened",
@@ -25579,6 +25580,8 @@
"unacknowledged": "ยังไม่รับทราบ · เริ่มการแจ้งเตือนเรียกเวรสำหรับ {team} ใหม่",
"unacknowledgedNoTeam": "ยังไม่รับทราบ · เริ่มการแจ้งเตือนเรียกเวรใหม่",
"unacknowledgeFailed": "ไม่สามารถยกเลิกการรับทราบการแจ้งเตือนเรียกเวรได้",
+ "unackAlertDeleted": "ไม่สามารถยกเลิกการรับทราบได้: การแจ้งเตือนของเพจนี้ถูกลบแล้ว",
+ "unackRefused": "ไม่สามารถยกเลิกการรับทราบเพจนี้ได้",
"takeOver": "รับช่วงต่อ",
"takeOverHint": "โอนการรับทราบให้คุณ",
"tookOver": "คุณรับช่วงต่อจาก {who}",
diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json
index 5ba16d1728..f7aa2cf16c 100644
--- a/web/src/locales/languages/tr-TR.json
+++ b/web/src/locales/languages/tr-TR.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Akış",
"firingHistory": "Son tetiklenmeler",
"firingHistoryHint": "Bu aynı konunun önceki her tetiklenmesi, en yenisi en üstte. Ne sıklıkta tetiklendiği ve birinin yanıt verip vermediği, gerçek bir sorunu ayarlanması gereken bir eşikten ayıran şeydir.",
- "firingHistoryEmpty": "Bu konu ilk kez birine çağrı gönderdi.",
+ "firingHistoryNone": "Daha önce tetiklenme yok",
+ "mineJoinTeam": "Bir ekibe katıl",
"neverAcknowledged": "Hiç onaylanmadı.",
"age": "Yaş",
"openedAt": "Açılma",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Kabul kaldırıldı · {team} için çağrı gönderme yeniden başlar",
"unacknowledgedNoTeam": "Kabul kaldırıldı · çağrı gönderme yeniden başlar",
"unacknowledgeFailed": "Çağrının kabulü kaldırılamadı",
+ "unackAlertDeleted": "Onay geri alınamıyor: bu sayfanın uyarısı silindi",
+ "unackRefused": "Bu sayfanın onayı geri alınamadı",
"takeOver": "Devral",
"takeOverHint": "Kabulü size taşır",
"tookOver": "{who} kişisinden devraldınız",
diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json
index 3b8a64b0f6..1d9d707266 100644
--- a/web/src/locales/languages/vi-VN.json
+++ b/web/src/locales/languages/vi-VN.json
@@ -25526,7 +25526,8 @@
"subjectStream": "Stream",
"firingHistory": "Các lần kích hoạt gần đây",
"firingHistoryHint": "Mọi lần kích hoạt trước đó của cùng đối tượng này, mới nhất trước. Tần suất kích hoạt và việc có ai phản hồi hay không chính là điều phân biệt một vấn đề thực sự với một ngưỡng cần điều chỉnh.",
- "firingHistoryEmpty": "Đây là lần đầu tiên đối tượng này gọi trực bất kỳ ai.",
+ "firingHistoryNone": "Không có lần kích hoạt trước đó",
+ "mineJoinTeam": "Tham gia một nhóm",
"neverAcknowledged": "Chưa từng được xác nhận",
"age": "Thời gian",
"openedAt": "Đã mở",
@@ -25581,6 +25582,8 @@
"unacknowledged": "Đã bỏ xác nhận · gọi trực bắt đầu lại cho {team}",
"unacknowledgedNoTeam": "Đã bỏ xác nhận · gọi trực bắt đầu lại",
"unacknowledgeFailed": "Không thể bỏ xác nhận thông báo trực",
+ "unackAlertDeleted": "Không thể hủy xác nhận: cảnh báo của trang này đã bị xóa",
+ "unackRefused": "Không thể hủy xác nhận trang này",
"takeOver": "Tiếp nhận",
"takeOverHint": "Chuyển xác nhận cho bạn",
"tookOver": "Bạn đã tiếp nhận từ {who}",
diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json
index 3429cc078f..b87b9ddbd3 100644
--- a/web/src/locales/languages/zh-CN.json
+++ b/web/src/locales/languages/zh-CN.json
@@ -25526,7 +25526,8 @@
"subjectStream": "数据流",
"firingHistory": "近期触发",
"firingHistoryHint": "同一主题此前的每次触发,最新的在前。触发频率以及是否有人响应,是区分真正问题与需要调整的阈值的关键。",
- "firingHistoryEmpty": "这是该主题首次向任何人发送告警通知。",
+ "firingHistoryNone": "没有更早的触发",
+ "mineJoinTeam": "加入团队",
"neverAcknowledged": "从未确认",
"age": "时长",
"openedAt": "开启时间",
@@ -25581,6 +25582,8 @@
"unacknowledged": "已取消确认 · 已重新开始呼叫 {team}",
"unacknowledgedNoTeam": "已取消确认 · 呼叫已重新开始",
"unacknowledgeFailed": "无法取消确认此呼叫",
+ "unackAlertDeleted": "无法取消确认:此页面的告警已被删除",
+ "unackRefused": "无法取消确认此页面",
"takeOver": "接管",
"takeOverHint": "将确认状态转交给你",
"tookOver": "你已从 {who} 处接管",
diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json
index ebf58e7c99..ed98e87c50 100644
--- a/web/src/locales/languages/zh-TW.json
+++ b/web/src/locales/languages/zh-TW.json
@@ -25521,7 +25521,8 @@
"subjectStream": "串流",
"firingHistory": "近期觸發記錄",
"firingHistoryHint": "此相同主題先前每次觸發的紀錄會依最新至最舊排列。觸發頻率以及是否有人回應,正是區分真實問題與需要調整的門檻的關鍵。",
- "firingHistoryEmpty": "這是此主題第一次呼叫任何人。",
+ "firingHistoryNone": "沒有更早的觸發",
+ "mineJoinTeam": "加入團隊",
"neverAcknowledged": "從未確認",
"age": "歷時",
"openedAt": "開啟時間",
@@ -25576,6 +25577,8 @@
"unacknowledged": "已取消認領 · 重新開始呼叫 {team}",
"unacknowledgedNoTeam": "已取消認領 · 重新開始呼叫",
"unacknowledgeFailed": "無法取消認領此呼叫",
+ "unackAlertDeleted": "無法取消確認:此頁面的告警已被刪除",
+ "unackRefused": "無法取消確認此頁面",
"takeOver": "接手",
"takeOverHint": "將認領轉移給您",
"tookOver": "您已從 {who} 接手",
diff --git a/web/src/views/OnCall/OnCallMine.spec.ts b/web/src/views/OnCall/OnCallMine.spec.ts
index 179693a37e..544562d5fd 100644
--- a/web/src/views/OnCall/OnCallMine.spec.ts
+++ b/web/src/views/OnCall/OnCallMine.spec.ts
@@ -123,7 +123,7 @@ const stubs = {
// not carry, and its fetch would throw into its own catch for the wrong reason.
OnCallMyDeliveries: {
name: "OnCallMyDeliveries",
- props: ["teamNames", "canTest"],
+ props: ["teamNames", "canTest", "noTeam"],
template: "",
methods: { refresh: deliveriesRefresh },
},
@@ -158,6 +158,17 @@ describe("OnCallMine", () => {
service.resolvedSchedule.mockResolvedValue({ data: [] } as any);
});
+ /// Mounts the real page layout so the tag is proven to reach the rendered header.
+ it("tags the page title as Beta", async () => {
+ const wrapper = mount(OnCallMine, {
+ global: { plugins: [i18n, store], stubs: { ...stubs, OPageLayout: false } },
+ });
+ await flushPromises();
+ const badge = wrapper.find('[data-test="beta-badge"]');
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe(String(i18n.global.t("common.beta")));
+ });
+
it("serves a revisit from cache", async () => {
const first = render();
await flushPromises();
@@ -493,10 +504,65 @@ describe("OnCallMine", () => {
expect(wrapper.find('[data-test="oncall-mine-shifts-toggle"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("Show all");
- // The fold is marked inside the frame with how many rows wait below it, and the mark goes once they are reached.
- expect(wrapper.find('[data-test="oncall-mine-shifts-more"]').text()).toBe("115 more");
+ // The fold is marked below the frame, never over a row; once reached its line empties but keeps its height.
+ const more = () => wrapper.find('[data-test="oncall-mine-shifts-more"]');
+ expect(more().text()).toBe("115 more");
+ expect(frame().find('[data-test="oncall-mine-shifts-more"]').exists()).toBe(false);
await scroller.trigger("scroll");
- expect(wrapper.find('[data-test="oncall-mine-shifts-more"]').exists()).toBe(false);
+ expect(more().exists()).toBe(true);
+ expect(more().text()).toBe("");
+ // Off duty holds no seat, so no team here needs the who-is-on-call read.
+ expect(service.whoIsOnCall).not.toHaveBeenCalled();
+ });
+
+ /// "Am I on call" outranks "when is my next shift", so a held seat sorts above an off-duty row that starts sooner.
+ it("sorts held seats first, then off-duty rows by their next shift", async () => {
+ const me = store.state.userInfo.email;
+ const now = Date.now() * 1000;
+ service.myOnCall.mockResolvedValue({
+ data: {
+ on_call_now: true,
+ teams: [
+ { team_id: "off_soon", team_name: "Off soon", on_call_now: false, on_call: [] },
+ { team_id: "held", team_name: "Held", on_call_now: true, on_call: [] },
+ ],
+ },
+ } as any);
+ service.whoIsOnCall.mockResolvedValue({
+ data: [{ rotation_id: "r1", rotation_name: "Primary", rule: "", user_email: me }],
+ } as any);
+ service.resolvedSchedule.mockImplementation(
+ (args: { team_id: string }) =>
+ Promise.resolve({
+ data:
+ args.team_id === "held"
+ ? [{ rotation_id: "r1", from: now - 1, to: now + 9 * 3_600_000_000, user_email: me }]
+ : [
+ {
+ rotation_id: "r2",
+ from: now + 60_000_000,
+ to: now + 120_000_000,
+ user_email: me,
+ },
+ ],
+ }) as any,
+ );
+ const wrapper = mount(OnCallMine, {
+ global: { plugins: [i18n, store], stubs: { ...stubs, OTable: false } },
+ });
+ await flushPromises();
+ const names = () =>
+ wrapper.findAll('[data-test^="oncall-mine-team-"]').map((link) => link.text());
+ await vi.waitFor(() => expect(names()).toEqual(["Held", "Off soon"]));
+ });
+
+ /// Away only takes someone off a rotation, so a reader on no team has nothing to mark.
+ it("hides I'm away and points the history at a team for a reader on no team", async () => {
+ service.myOnCall.mockResolvedValue({ data: { on_call_now: false, teams: [] } } as any);
+ const wrapper = render();
+ await flushPromises();
+ expect(wrapper.find('[data-test="oncall-mine-away"]').exists()).toBe(false);
+ expect(wrapper.findComponent({ name: "OnCallMyDeliveries" }).props("noTeam")).toBe(true);
});
it("marks no fold when every shift fits the frame", async () => {
diff --git a/web/src/views/OnCall/OnCallMine.vue b/web/src/views/OnCall/OnCallMine.vue
index fb628bc11c..33ddb5e7e7 100644
--- a/web/src/views/OnCall/OnCallMine.vue
+++ b/web/src/views/OnCall/OnCallMine.vue
@@ -23,10 +23,14 @@ along with this program. If not, see .
icon="person"
scroll
>
+
+
+
+
.
@@ -302,15 +306,22 @@ along with this program. If not, see .
-
+
+
+
-
- {{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
-
-
+
+ {{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
+
@@ -344,7 +355,9 @@ along with this program. If not, see .
:teams-state="teamsState"
:test-team-name="testTeam?.team_name ?? null"
:can-test="!!testTeam"
+ :no-team="loaded && loadError === null && !(mine?.teams ?? []).length"
@test-page="askTestPage(testTeam?.team_id ?? '')"
+ @join-team="openTeams"
/>
@@ -454,6 +467,7 @@ import OnCallReachRow from "@/components/oncall/OnCallReachRow.vue";
import OnCallSetupBanner from "@/components/oncall/OnCallSetupBanner.vue";
import OnCallSlackCard from "@/components/oncall/OnCallSlackCard.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
+import BetaBadge from "@/components/common/BetaBadge.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OContent from "@/lib/core/Content/OContent.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
@@ -581,10 +595,21 @@ function untilAt(row: ShiftRow): number {
return Number.POSITIVE_INFINITY;
}
+/// Held seats first, then off-duty, then unknown: "am I on call" outranks "when is my next shift".
+function dutyRank(row: ShiftRow): number {
+ if (row.on_call_now === true) return 0;
+ return row.on_call_now === false ? 1 : 2;
+}
+
const shiftRows = computed(() =>
(mine.value?.teams ?? [])
.map((team) => ({ ...team, ...(shifts.value[team.team_id] ?? NO_FACTS) }))
- .sort((a, b) => untilAt(a) - untilAt(b) || a.team_name.localeCompare(b.team_name)),
+ .sort(
+ (a, b) =>
+ dutyRank(a) - dutyRank(b) ||
+ untilAt(a) - untilAt(b) ||
+ a.team_name.localeCompare(b.team_name),
+ ),
);
/// Same as the frame's five rows, so the first paint is the loaded height.
@@ -723,10 +748,11 @@ async function fetchShifts(force = false) {
const entries = await Promise.all(
teams.map(async (team): Promise<[string, ShiftFacts] | null> => {
try {
- const positions = await read(
- whoIsOnCallQuery(orgId.value, team.team_id),
- force,
- );
+ // Off duty holds no seat, so only a held shift needs the who-is-on-call read.
+ const positions =
+ team.on_call_now === false
+ ? []
+ : await read(whoIsOnCallQuery(orgId.value, team.team_id), force);
const held = positions.filter((p) => p.user_email.toLowerCase() === me);
const segments = await read(
resolvedScheduleQuery(
diff --git a/web/src/views/OnCall/OnCallResponseDetail.spec.ts b/web/src/views/OnCall/OnCallResponseDetail.spec.ts
index c4c45374d2..d03c716933 100644
--- a/web/src/views/OnCall/OnCallResponseDetail.spec.ts
+++ b/web/src/views/OnCall/OnCallResponseDetail.spec.ts
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
+import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/locales";
@@ -584,6 +584,98 @@ describe("OnCallResponseDetail", () => {
});
});
+ /// The server answers an unack it cannot apply with 200 unchanged and the page still held.
+ /// Engine bookkeeping is folded by default; the toggle names how much it hides and appears only when there is some.
+ it("folds system activity until asked for", async () => {
+ service.getResponse.mockResolvedValue({
+ data: {
+ response: record(),
+ events: [
+ { kind: "sys", at: 1, actor: "o2-engine", body: "this signal carries no service" },
+ { kind: "note", at: 2, actor: "ana@acme.io", body: "looking" },
+ ],
+ },
+ } as any);
+ const wrapper = render();
+ await flushPromises();
+ const toggle = wrapper.find('[data-test="oncall-response-activity-toggle-all"]');
+ expect(toggle.text()).toContain("1");
+ expect(wrapper.findComponent({ name: "OnCallActivityTimeline" }).props("showAll")).toBe(false);
+ });
+
+ it("offers no system toggle when there is no system activity", async () => {
+ const wrapper = await renderWith();
+ expect(wrapper.find('[data-test="oncall-response-activity-toggle-all"]').exists()).toBe(false);
+ });
+
+ /// A long single-token alert name must ellipsise rather than run under the state chips beside it.
+ it("cuts a long title and keeps the state chips at full width", async () => {
+ const wrapper = await renderWith({
+ state: "acknowledged",
+ acked_by: "ana@acme.io",
+ title: "composite_clone_child_a_muv1p0nee7qa",
+ });
+ const layout = wrapper.findComponent({ name: "OPageLayout" });
+ expect(layout.attributes("title")).toBe("composite_clone_child_a_muv1p0nee7qa");
+ // "visible" lets the h1 overflow instead of ellipsising, which is what ran the title under the chips.
+ expect(layout.attributes("title-overflow")).toBeUndefined();
+ expect(wrapper.find('[data-test="oncall-response-state-tag"]').classes()).toContain("shrink-0");
+ });
+
+ /// Who holds the page reads with team and time, so the title row keeps its width for the title.
+ it("names the holder of an acknowledged page in the subtitle, not beside the title", async () => {
+ const wrapper = await renderWith({ state: "acknowledged", acked_by: "ana@acme.io" });
+ expect(wrapper.find('[data-test="oncall-response-phrase"]').exists()).toBe(false);
+ const acked = wrapper.find('[data-test="oncall-response-acked-by"]');
+ expect(acked.text()).toContain("Acknowledged by");
+ expect(acked.findComponent({ name: "OnCallPersonName" }).props("email")).toBe("ana@acme.io");
+ });
+
+ describe("an Unacknowledge the server cannot apply", () => {
+ const held = () => ({
+ state: "acknowledged",
+ acked_by: store.state.userInfo.email,
+ title: "checkout down",
+ });
+ const unackBtn = (wrapper: VueWrapper) =>
+ wrapper.findComponent('[data-test="oncall-response-unack-btn"]');
+
+ it("warns instead of claiming the page was never acknowledged, then turns the button off", async () => {
+ const wrapper = await renderWith(held());
+ service.unacknowledgeResponse.mockResolvedValue({
+ data: { ...record(held()), ack: { changed: false } },
+ } as any);
+ await unackBtn(wrapper).trigger("click");
+ await flushPromises();
+
+ expect(toastSpy.mock.calls.at(-1)?.[0]).toEqual({
+ variant: "warning",
+ message: "Couldn't unacknowledge this page",
+ });
+ expect(unackBtn(wrapper).props("disabled")).toBe(true);
+ expect(unackBtn(wrapper).findComponent({ name: "OTooltip" }).props("content")).toBe(
+ "Couldn't unacknowledge this page",
+ );
+ });
+
+ it("blames the deleted alert when the lookup said it is gone", async () => {
+ alerts.get_by_alert_id.mockRejectedValue({ response: { status: 404 } });
+ const wrapper = await renderWith(held());
+
+ expect(unackBtn(wrapper).props("disabled")).toBe(true);
+ expect(unackBtn(wrapper).findComponent({ name: "OTooltip" }).props("content")).toBe(
+ "Can't unacknowledge: this page's alert was deleted",
+ );
+ });
+
+ 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());
+
+ expect(unackBtn(wrapper).props("disabled")).toBe(false);
+ });
+ });
+
it("does not offer to take over one's own acknowledgement", async () => {
const wrapper = await renderWith({
state: "acknowledged",
diff --git a/web/src/views/OnCall/OnCallResponseDetail.vue b/web/src/views/OnCall/OnCallResponseDetail.vue
index 9611514113..d6c5acb6bb 100644
--- a/web/src/views/OnCall/OnCallResponseDetail.vue
+++ b/web/src/views/OnCall/OnCallResponseDetail.vue
@@ -4,7 +4,6 @@
data-test="oncall-response-detail-page"
:title="title"
icon="notifications-active"
- title-overflow="visible"
tabs-below
:back="{
label: t('oncall.backToResponses'),
@@ -18,46 +17,65 @@
instead of a trailing clause, which is enough for "not the first
time" without spelling out how many. -->
-
-
- {{ t("oncall.statUnrouted") }}
-
- {{ t("oncall.deletedTeam") }}
-
-
-
- {{ raw(teamName) }}
-
-
-
- {{ openedAtClock }}
-
-
+
+
+
+ {{ t("oncall.statUnrouted") }}
+
+ {{ t("oncall.deletedTeam") }}
+
+
+
+ {{ raw(teamName) }}
+
+
+
+ {{ openedAtClock }}
+
+
+
+
+ {{ raw("·") }}
+
+
+
+
+
+
+
-
+
+
-
-
-
+
+
+
- {{ t("oncall.acknowledge") }}
+ {{ t("oncall.acknowledge") }}
- {{ t("oncall.takeOver") }}
+ {{ t("oncall.takeOver") }}
+
+
{{ t("oncall.unacknowledge") }}
-
+ {{
+ unackBlockedReason
+ }}
@@ -464,7 +494,7 @@
-
+
(null);
+/// True once the alert lookup answers 404: the page outlived its alert, and the server can no longer release it.
+const subjectAlertMissing = ref(false);
+/// Why the last Unacknowledge left the page held; cleared when another page loads.
+const unackRefusedReason = ref(null);
+const unackBlockedReason = computed(() =>
+ subjectAlertMissing.value ? t("oncall.unackAlertDeleted") : unackRefusedReason.value,
+);
const escalation = ref(null);
/// False until the progress has answered, so the header makes no paging claim from a missing value.
const escalationLoaded = ref(false);
@@ -1463,11 +1502,19 @@ async function unacknowledgeRecord() {
acking.value = true;
try {
const res = await unackWrite.mutateAsync(responseId.value);
+ // The server answers 200 unchanged when it cannot find the alert to release, so the page stays held.
+ const stillHeld = res.data.ack?.changed === false && res.data.state === "acknowledged";
+ const refused = subjectAlertMissing.value
+ ? t("oncall.unackAlertDeleted")
+ : t("oncall.unackRefused");
+ if (stillHeld) unackRefusedReason.value = refused;
toast(
- res.data.ack?.changed === false
- ? (resolvedToast(res.data, false) ??
+ stillHeld
+ ? { variant: "warning", message: refused }
+ : res.data.ack?.changed === false
+ ? (resolvedToast(res.data, false) ??
notAckedToast(res.data.title || res.data.subject.source_id, t))
- : { variant: "success", message: ackWords.value.unacked },
+ : { variant: "success", message: ackWords.value.unacked },
);
await fetchResponse();
} catch (err: any) {
@@ -1580,12 +1627,14 @@ async function fetchHandoffTargets() {
// Uncached: the page links to the alert editor, which seeds its form from the cached alert entry.
async function fetchSubjectAlert() {
subjectAlert.value = null;
+ subjectAlertMissing.value = false;
if (response.value?.subject.subject_type !== "alert") return;
try {
const res = await alertsService.get_by_alert_id(orgId.value, response.value.subject.source_id);
subjectAlert.value = res.data ?? null;
- } catch {
+ } catch (err: any) {
subjectAlert.value = null;
+ subjectAlertMissing.value = err?.response?.status === 404;
}
}
@@ -1905,6 +1954,8 @@ watch(
incidentLoading.value = true;
timelineReady.value = false;
subjectAlert.value = null;
+ subjectAlertMissing.value = false;
+ unackRefusedReason.value = null;
priorCauses.value = [];
firingHistory.value = [];
report.value = null;
diff --git a/web/src/views/OnCall/OnCallResponses.spec.ts b/web/src/views/OnCall/OnCallResponses.spec.ts
index 23f139a3bf..b23fc8ae5b 100644
--- a/web/src/views/OnCall/OnCallResponses.spec.ts
+++ b/web/src/views/OnCall/OnCallResponses.spec.ts
@@ -313,6 +313,17 @@ describe("OnCallResponses", () => {
service.getResponse.mockResolvedValue({ data: { events: [] } } as any);
});
+ /// Mounts the real page layout so the tag is proven to reach the rendered header.
+ it("tags the page title as Beta", async () => {
+ const wrapper = mountTracked(OnCallResponses, {
+ global: { plugins: [i18n, store], stubs: { ...stubs, OPageLayout: false } },
+ });
+ await flushPromises();
+ const badge = wrapper.find('[data-test="beta-badge"]');
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe(String(i18n.global.t("common.beta")));
+ });
+
function page(over: Record = {}, source = "al_ckt") {
return {
id: "resp_1",
@@ -767,8 +778,8 @@ describe("OnCallResponses", () => {
expect(wrapper.find('[data-test^="oncall-row-snooze-"]').exists()).toBe(true);
});
- /// Pages are forward-only, so a resolved row has no Acknowledge or Resolve, yet its ⋯ still opens, expands and links it.
- it("gives a resolved row only the ⋯ menu: open, timeline, copy link", async () => {
+ /// Pages are forward-only, so a resolved row has no Acknowledge or Resolve; the row click opens it, so ⋯ only expands and links it.
+ it("gives a resolved row only the ⋯ menu: timeline, copy link", async () => {
const wrapper = await withPages([
page({ state: "resolved", closed_at: Date.now() * 1000, acked_by: "ana@o2.ai" }),
]);
@@ -776,7 +787,7 @@ describe("OnCallResponses", () => {
expect(wrapper.find('[data-test^="oncall-row-ack-"]').exists()).toBe(false);
expect(wrapper.find('[data-test^="oncall-row-resolve-"]').exists()).toBe(false);
expect(wrapper.find('[data-test="oncall-row-more-alert:al_ckt"]').exists()).toBe(true);
- expect(wrapper.find('[data-test="oncall-row-open-alert:al_ckt"]').exists()).toBe(true);
+ expect(wrapper.find('[data-test="oncall-row-open-alert:al_ckt"]').exists()).toBe(false);
expect(wrapper.find('[data-test="oncall-row-copy-link-alert:al_ckt"]').exists()).toBe(true);
const timeline = wrapper.findComponent('[data-test="oncall-row-show-timeline-alert:al_ckt"]');
expect(timeline.text()).toBe("Show timeline");
diff --git a/web/src/views/OnCall/OnCallResponses.vue b/web/src/views/OnCall/OnCallResponses.vue
index f5e6dedc83..92effa9fac 100644
--- a/web/src/views/OnCall/OnCallResponses.vue
+++ b/web/src/views/OnCall/OnCallResponses.vue
@@ -21,6 +21,9 @@ along with this program. If not, see .
:title="t('oncall.responsesTitle')"
icon="notifications-active"
>
+
+
+
@@ -708,13 +711,6 @@ along with this program. If not, see .
>
{{ t("oncall.resolve") }}
-
- {{ t("oncall.rowOpenPage") }}
-
{
correlation.getDimensionAnalytics.mockResolvedValue({ data: { dimensions: [] } } as any);
});
+ /// Mounts the real page layout so the tag is proven to reach the rendered header.
+ it("tags the page title as Beta", async () => {
+ const wrapper = mount(OnCallRouting, {
+ global: { plugins: [i18n, store], stubs: { ...stubs, OPageLayout: false } },
+ });
+ await flushPromises();
+ const badge = wrapper.find('[data-test="beta-badge"]');
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe(String(i18n.global.t("common.beta")));
+ });
+
/// The whole point of the org screen: no team_id, so shadowing is computed
/// across every team's rules, not one team's slice.
it("asks for the org-wide rules, not one team's", async () => {
diff --git a/web/src/views/OnCall/OnCallRouting.vue b/web/src/views/OnCall/OnCallRouting.vue
index f895e5b945..8f3bfa2725 100644
--- a/web/src/views/OnCall/OnCallRouting.vue
+++ b/web/src/views/OnCall/OnCallRouting.vue
@@ -23,6 +23,7 @@ along with this program. If not, see .
icon="alt-route"
>
+
{
service.getRoutingConfig.mockResolvedValue({ data: { default_team_id: null } } as any);
});
+ /// Mounts the real page layout so the tag is proven to reach the rendered header.
+ it("tags the page title as Beta", async () => {
+ const wrapper = mount(OnCallTeams, {
+ global: { plugins: [i18n, store], stubs: { ...stubs, OPageLayout: false } },
+ });
+ await flushPromises();
+ const badge = wrapper.find('[data-test="beta-badge"]');
+ expect(badge.exists()).toBe(true);
+ expect(badge.text()).toBe(String(i18n.global.t("common.beta")));
+ });
+
/// The setup checklist's first step sends somebody here already meaning to
/// create a team. Landing them on the list to hunt for the button is the same
/// click asked twice.
diff --git a/web/src/views/OnCall/OnCallTeams.vue b/web/src/views/OnCall/OnCallTeams.vue
index 7a5d6b5762..070acccb29 100644
--- a/web/src/views/OnCall/OnCallTeams.vue
+++ b/web/src/views/OnCall/OnCallTeams.vue
@@ -21,6 +21,9 @@ along with this program. If not, see .
:title="t('oncall.teamsTitle')"
icon="group-work"
>
+
+
+