mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Merge branch 'fix/oncall-ux-pages' into fix/oncall-ux-polish
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import OnCallEscalationCell from "@/components/oncall/OnCallEscalationCell.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
@@ -22,6 +22,14 @@ import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import type { EscalationProgress, FiredRung, OnCallResponse } from "@/ts/interfaces/oncall";
|
||||
|
||||
vi.mock("@/services/users", () => ({
|
||||
default: {
|
||||
orgUsers: vi.fn().mockResolvedValue({
|
||||
data: { data: [{ email: "pt@russia.com", first_name: "Pyotr", last_name: "Tkachenko" }] },
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const stubs = {
|
||||
OIcon: { name: "OIcon", template: "<span />" },
|
||||
OProgressBar: { name: "OProgressBar", props: ["value", "variant"], template: "<div />" },
|
||||
@@ -73,6 +81,14 @@ const text = (wrapper: VueWrapper) =>
|
||||
wrapper.find('[data-test="oncall-escalation-cell-level"]').text();
|
||||
|
||||
describe("OnCallEscalationCell", () => {
|
||||
/// Two people can share a name, so the email rides in the tooltip rather than the phrase.
|
||||
it("names who acknowledged by display name, with name and email in the tooltip", async () => {
|
||||
const wrapper = render({ state: "acknowledged", acked_by: "pt@russia.com" }, null);
|
||||
await flushPromises();
|
||||
expect(text(wrapper)).toBe("Acknowledged by Pyotr Tkachenko");
|
||||
expect(tooltipText(wrapper)).toContain("Acknowledged by Pyotr Tkachenko (pt@russia.com)");
|
||||
});
|
||||
|
||||
/// One phrase per row, and never the bare state word the group header already says.
|
||||
it.each([
|
||||
[{}, { fired: firedRungs(2, ["bo@o2.ai"]) }, {}, "Paging step 2 of 3 · bo@o2.ai"],
|
||||
|
||||
@@ -53,6 +53,7 @@ import OTag from "@/lib/core/Badge/OTag.vue";
|
||||
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
|
||||
|
||||
import { useOnCallClock } from "@/composables/useOnCallClock";
|
||||
import { useOrgUserNames } from "@/composables/useOrgUserNames";
|
||||
import type { EscalationProgress, OnCallResponse } from "@/ts/interfaces/oncall";
|
||||
import type { I18nText } from "@/types/i18n";
|
||||
import { raw, useI18nTyped } from "@/types/i18n";
|
||||
@@ -79,6 +80,7 @@ const props = withDefaults(
|
||||
const { t } = useI18nTyped();
|
||||
const store = useStore();
|
||||
const nowMicros = useOnCallClock();
|
||||
const { nameOf, tooltipOf } = useOrgUserNames();
|
||||
|
||||
const phrase = computed(() =>
|
||||
pagePhrase({
|
||||
@@ -133,7 +135,7 @@ const headline = computed<I18nText>(() => {
|
||||
: t("oncall.resolvedAfter", { duration: raw(formatMicrosDuration(p.afterMicros)) });
|
||||
case "acknowledged":
|
||||
return p.by
|
||||
? t("oncall.escalationAckedBy", { who: raw(p.by) })
|
||||
? t("oncall.escalationAckedBy", { who: raw(nameOf(p.by)) })
|
||||
: t("oncall.state_acknowledged");
|
||||
case "snoozed":
|
||||
return t("oncall.escalationSnoozedUntil", {
|
||||
@@ -197,7 +199,15 @@ const detail = computed<I18nText | "">(() => {
|
||||
return t("oncall.escalationTo", { who: raw(progress.next_targets.join(", ")), when });
|
||||
});
|
||||
|
||||
/// The headline names the person; the tooltip adds their email, since two people can share a name.
|
||||
const tooltipHeadline = computed<I18nText>(() => {
|
||||
const p = phrase.value;
|
||||
return p.kind === "acknowledged" && p.by
|
||||
? t("oncall.escalationAckedBy", { who: tooltipOf(p.by) })
|
||||
: headline.value;
|
||||
});
|
||||
|
||||
const tooltipContent = computed<I18nText>(() =>
|
||||
detail.value ? raw(`${headline.value} — ${detail.value}`) : headline.value,
|
||||
detail.value ? raw(`${tooltipHeadline.value} — ${detail.value}`) : tooltipHeadline.value,
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,7 @@ import store from "@/test/unit/helpers/store";
|
||||
const stubs = {
|
||||
OTable: {
|
||||
name: "OTable",
|
||||
props: ["data", "columns", "loading"],
|
||||
props: ["data", "columns", "loading", "pagination"],
|
||||
template: `<div>
|
||||
<div v-if="loading" data-test="skeleton" />
|
||||
<div v-else-if="!data.length"><slot name="empty" /></div>
|
||||
@@ -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
|
||||
|
||||
@@ -33,7 +33,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
<span v-else class="text-text-secondary text-sm">{{ ABSENT }}</span>
|
||||
</template>
|
||||
|
||||
<!-- A subject firing for the first time is the normal case, not an
|
||||
error, so it says so rather than showing an empty frame. -->
|
||||
<template #empty>
|
||||
<OEmptyState
|
||||
size="inline"
|
||||
preset="no-data"
|
||||
:description="t('oncall.firingHistoryEmpty')"
|
||||
variant="no-results"
|
||||
:title="t('oncall.firingHistoryNone')"
|
||||
hide-action
|
||||
data-test="oncall-firing-history-empty"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -109,7 +109,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
{{ t("oncall.myDeliveriesUnread", { count: unread }, unread) }}
|
||||
</OTag>
|
||||
|
||||
<span v-if="unread || unreadOnly" class="ms-auto flex flex-wrap items-center gap-2">
|
||||
<!-- Hidden, not removed, so the header does not reflow when the first unread arrives. -->
|
||||
<span
|
||||
class="ms-auto flex flex-wrap items-center gap-2"
|
||||
:class="unread || unreadOnly ? '' : 'invisible'"
|
||||
data-test="oncall-my-deliveries-controls"
|
||||
>
|
||||
<OCheckbox
|
||||
:model-value="unreadOnly"
|
||||
:label="t('oncall.myDeliveriesUnreadOnly')"
|
||||
@@ -117,9 +122,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@update:model-value="(v: CheckboxModelValue) => setUnreadOnly(!!v)"
|
||||
/>
|
||||
<OButton
|
||||
v-if="unread"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="unread ? '' : 'invisible'"
|
||||
:loading="marking"
|
||||
data-test="oncall-my-deliveries-read-all"
|
||||
@click="markAll"
|
||||
@@ -214,7 +219,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
{{ raw(clock(row.latest.at)) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<template #cell-row-actions="{ row }">
|
||||
<div class="flex items-center justify-end">
|
||||
<!-- An open tooltip is the top dismissable layer, so it would take the menu's Escape. -->
|
||||
<ODropdown
|
||||
@@ -260,16 +265,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
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()"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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<MyDelivery[]>(() => {
|
||||
const byPage = new Map<string, MyDelivery>();
|
||||
@@ -415,8 +439,10 @@ const openRows = computed<MyDelivery[]>(() => {
|
||||
const historyRows = computed<HistoryRow[]>(() => {
|
||||
const groups = new Map<string, MyDelivery[]>();
|
||||
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<OTableColumnDef<MyDelivery>[]>(() => [
|
||||
]);
|
||||
|
||||
const historyColumns = computed<OTableColumnDef<HistoryRow>[]>(() => [
|
||||
// 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<OTableColumnDef<HistoryRow>[]>(() => [
|
||||
{ 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" },
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</i18n-t>
|
||||
<span v-else :class="responderTone" data-test="oncall-simulator-responder">
|
||||
{{ responderNote }}
|
||||
<OTooltip
|
||||
v-if="responderWhy"
|
||||
side="bottom"
|
||||
:content="responderWhy"
|
||||
data-test="oncall-simulator-responder-why"
|
||||
/>
|
||||
</span>
|
||||
<template v-if="nextStep">
|
||||
<span>{{ raw(" · ") }}</span>
|
||||
@@ -537,18 +543,27 @@ const responderNote = computed<I18nText>(() => {
|
||||
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<I18nText | null>(() => {
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -106,9 +106,21 @@ async function read<T>(
|
||||
return queryClient.fetchQuery(options as any) as Promise<T>;
|
||||
}
|
||||
|
||||
/// 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<OnCallPolicy | null>(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<OnCallTeam[]>(oncallTeamsQuery(org), force),
|
||||
@@ -116,15 +128,13 @@ async function load(force = false) {
|
||||
read<OwnershipRule[]>(ownershipRulesQuery(org), force),
|
||||
read<OnCallResponse[]>(responsesQuery(org, { include_resolved: true, limit: 20 }), force),
|
||||
]);
|
||||
const policies = await Promise.allSettled(
|
||||
teamList.map((team) => read<OnCallPolicy | null>(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),
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -163,28 +163,13 @@ const connectWithTicket = async (): Promise<void> => {
|
||||
const candidate = computed(() => props.upgradeCandidates[0] ?? null);
|
||||
const addToSlackSrcset = `${addToSlackImage2x} 2x`;
|
||||
|
||||
const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
const nextSteps = computed<I18nText[]>(() =>
|
||||
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"),
|
||||
],
|
||||
);
|
||||
</script>
|
||||
@@ -290,14 +275,9 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
class="gap-3 p-5"
|
||||
data-test="slack-available"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-text-heading m-0 text-lg font-semibold">
|
||||
{{ t("slackApp.connect.existingTitle") }}
|
||||
</h2>
|
||||
<p class="text-text-secondary m-0 text-sm">
|
||||
{{ t("slackApp.connect.existingDescriptionShort") }}
|
||||
</p>
|
||||
</div>
|
||||
<h2 class="text-text-heading m-0 text-lg font-semibold">
|
||||
{{ t("slackApp.connect.existingTitle") }}
|
||||
</h2>
|
||||
<div
|
||||
v-for="item in offered"
|
||||
:key="item.installation_id"
|
||||
@@ -322,7 +302,6 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
size="block"
|
||||
illustration="connect"
|
||||
:title="t('slackApp.connect.title')"
|
||||
:description="t('slackApp.connect.description')"
|
||||
:backdrop="false"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -355,9 +334,6 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
</OButton>
|
||||
</template>
|
||||
<template #extra>
|
||||
<span class="text-text-label text-2xs font-semibold">
|
||||
{{ t("slackApp.connect.nextTitle", { minutes: isCloud ? 2 : 5 }) }}
|
||||
</span>
|
||||
<ol
|
||||
class="m-0 grid w-full max-w-2xl list-none grid-cols-1 gap-3 p-0 text-start"
|
||||
:class="nextSteps.length === 3 ? 'md:grid-cols-3' : 'md:grid-cols-2'"
|
||||
@@ -367,7 +343,7 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
<li
|
||||
v-for="(item, index) in nextSteps"
|
||||
:key="index"
|
||||
class="border-border-subtle bg-surface-panel rounded-default flex items-start gap-2.5 border p-3"
|
||||
class="border-border-subtle bg-surface-panel rounded-default flex items-center gap-2.5 border p-3"
|
||||
data-test="slack-connect-step"
|
||||
>
|
||||
<span
|
||||
@@ -375,10 +351,7 @@ const nextSteps = computed<{ title: I18nText; detail: I18nText }[]>(() =>
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="text-text-heading text-compact font-medium">{{ item.title }}</span>
|
||||
<span class="text-text-secondary text-xs">{{ item.detail }}</span>
|
||||
</span>
|
||||
<span class="text-text-heading text-compact min-w-0 font-medium">{{ item }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<span v-if="!isCloud && instance" class="text-text-secondary text-xs">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void> => {
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
<span v-else class="text-text-secondary text-sm" data-test="slack-linking-summary">
|
||||
<span
|
||||
v-else-if="summary"
|
||||
class="text-text-secondary text-sm"
|
||||
data-test="slack-linking-summary"
|
||||
>
|
||||
{{ summary }}
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs" data-test="slack-linking-counts">
|
||||
@@ -256,14 +261,9 @@ const runLinkAll = async (): Promise<void> => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="text-text-heading text-sm font-medium">
|
||||
{{ t("slackApp.linking.appHome") }}
|
||||
</span>
|
||||
<span class="text-text-secondary text-xs">
|
||||
{{ t("slackApp.linking.appHomeDescription") }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-text-heading text-sm font-medium">
|
||||
{{ t("slackApp.linking.appHome") }}
|
||||
</span>
|
||||
<OTag variant="success-soft" :label="t('slackApp.linking.enabled')" />
|
||||
</div>
|
||||
</OCard>
|
||||
|
||||
@@ -597,7 +597,6 @@ const saveHelpContact = async (value: HelpContactForm): Promise<void> => {
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
class="border-border-default shrink-0 border-b"
|
||||
/>
|
||||
<ConstrainedPage
|
||||
:size="activeSection === 'password_policy' ? 'full' : 'lg'"
|
||||
:size="FULL_WIDTH_SECTIONS.has(activeSection) ? 'full' : 'lg'"
|
||||
align="left"
|
||||
:padded="false"
|
||||
class="min-h-0 flex-1 px-4 py-3"
|
||||
@@ -136,6 +136,8 @@ export default defineComponent({
|
||||
"slack",
|
||||
]);
|
||||
const isConstrainedSection = computed(() => 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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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} から引き継ぎました",
|
||||
|
||||
@@ -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}에게서 넘겨받았습니다",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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} 处接管",
|
||||
|
||||
@@ -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} 接手",
|
||||
|
||||
@@ -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: "<div />",
|
||||
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 () => {
|
||||
|
||||
@@ -23,10 +23,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
icon="person"
|
||||
scroll
|
||||
>
|
||||
<template #title-trail>
|
||||
<BetaBadge class="shrink-0" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<!-- Personal and org-wide, so it heads the page rather than sitting under one team. -->
|
||||
<!-- Away only takes someone off a team's rotation, so a reader on no team has nothing to mark. -->
|
||||
<OButton
|
||||
v-if="!unavailable"
|
||||
v-if="!unavailable && (!loaded || (mine?.teams ?? []).length)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!loaded"
|
||||
@@ -203,7 +207,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<!-- Scroll events do not bubble, so the frame listens in the capture phase to the table's own scroller. -->
|
||||
<div
|
||||
v-else
|
||||
class="relative h-55.5 shrink-0"
|
||||
class="h-55.5 shrink-0"
|
||||
data-test="oncall-mine-shifts-frame"
|
||||
@scroll.capture="onShiftsScroll"
|
||||
>
|
||||
@@ -302,15 +306,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</div>
|
||||
</template>
|
||||
</OTable>
|
||||
<div
|
||||
v-if="loaded && shiftsBelow > 0"
|
||||
class="from-surface-base pointer-events-none absolute inset-x-0 bottom-0 flex h-8 items-end justify-center bg-linear-to-t to-transparent pb-1"
|
||||
data-test="oncall-mine-shifts-more"
|
||||
</div>
|
||||
<!-- Below the rows, never over them; the line keeps its height once scrolled so the card does not jump. -->
|
||||
<div
|
||||
v-if="!isMobile && loaded && shiftRows.length > SHIFTS_SKELETON_ROWS"
|
||||
class="border-border-default flex h-7 shrink-0 items-center justify-center gap-1 border-t"
|
||||
data-test="oncall-mine-shifts-more"
|
||||
>
|
||||
<OText
|
||||
v-if="shiftsBelow > 0"
|
||||
variant="meta"
|
||||
as="span"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<OTag variant="default-outline" size="sm" class="bg-surface-base! shadow-xs">
|
||||
{{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
|
||||
</OTag>
|
||||
</div>
|
||||
<OIcon name="expand-more" size="sm" aria-hidden="true" />
|
||||
{{ t("oncall.mineShiftsMore", { count: shiftsBelow }, shiftsBelow) }}
|
||||
</OText>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -344,7 +355,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
: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"
|
||||
/>
|
||||
</OContent>
|
||||
|
||||
@@ -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<ShiftRow[]>(() =>
|
||||
(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<OnCallPosition[]>(
|
||||
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<OnCallPosition[]>(whoIsOnCallQuery(orgId.value, team.team_id), force);
|
||||
const held = positions.filter((p) => p.user_email.toLowerCase() === me);
|
||||
const segments = await read<ResolvedSegment[]>(
|
||||
resolvedScheduleQuery(
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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",
|
||||
|
||||
@@ -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. -->
|
||||
<template v-if="response" #subtitle>
|
||||
<i18n-t
|
||||
keypath="oncall.responseSubtitle"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
data-test="oncall-response-subtitle"
|
||||
>
|
||||
<template #team>
|
||||
<span
|
||||
v-if="!response.team_id"
|
||||
class="text-status-error-text shrink-0"
|
||||
data-test="oncall-response-team-none"
|
||||
>{{ t("oncall.statUnrouted") }}</span
|
||||
>
|
||||
<OTag
|
||||
v-else-if="teamDeleted"
|
||||
variant="default-soft"
|
||||
size="sm"
|
||||
data-test="oncall-response-team-deleted"
|
||||
>
|
||||
{{ t("oncall.deletedTeam") }}
|
||||
</OTag>
|
||||
<OSkeleton
|
||||
v-else-if="!teamName"
|
||||
class="h-3.5 w-20"
|
||||
data-test="oncall-response-team-pending"
|
||||
/>
|
||||
<router-link
|
||||
v-else
|
||||
class="text-accent min-w-0 hover:underline"
|
||||
:to="teamRoute"
|
||||
data-test="oncall-response-team-link"
|
||||
>
|
||||
<OTruncatedText class="block">{{ raw(teamName) }}</OTruncatedText>
|
||||
</router-link>
|
||||
</template>
|
||||
<template #opened>
|
||||
<span class="shrink-0" data-test="oncall-response-opened">{{ openedAtClock }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<span class="flex min-w-0 items-center gap-1 overflow-hidden whitespace-nowrap">
|
||||
<i18n-t
|
||||
keypath="oncall.responseSubtitle"
|
||||
tag="span"
|
||||
scope="global"
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
data-test="oncall-response-subtitle"
|
||||
>
|
||||
<template #team>
|
||||
<span
|
||||
v-if="!response.team_id"
|
||||
class="text-status-error-text shrink-0"
|
||||
data-test="oncall-response-team-none"
|
||||
>{{ t("oncall.statUnrouted") }}</span
|
||||
>
|
||||
<OTag
|
||||
v-else-if="teamDeleted"
|
||||
variant="default-soft"
|
||||
size="sm"
|
||||
data-test="oncall-response-team-deleted"
|
||||
>
|
||||
{{ t("oncall.deletedTeam") }}
|
||||
</OTag>
|
||||
<OSkeleton
|
||||
v-else-if="!teamName"
|
||||
class="h-3.5 w-20"
|
||||
data-test="oncall-response-team-pending"
|
||||
/>
|
||||
<router-link
|
||||
v-else
|
||||
class="text-accent min-w-0 hover:underline"
|
||||
:to="teamRoute"
|
||||
data-test="oncall-response-team-link"
|
||||
>
|
||||
<OTruncatedText class="block">{{ raw(teamName) }}</OTruncatedText>
|
||||
</router-link>
|
||||
</template>
|
||||
<template #opened>
|
||||
<span class="shrink-0" data-test="oncall-response-opened">{{ openedAtClock }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<!-- The holder reads with team and time, and gives way first when the row is narrow. -->
|
||||
<OTruncatedText
|
||||
v-if="response.state === 'acknowledged' && response.acked_by"
|
||||
class="shrink-[100]"
|
||||
:tooltip="t('oncall.escalationAckedBy', { who: tooltipOf(response.acked_by) })"
|
||||
data-test="oncall-response-acked-by"
|
||||
>
|
||||
{{ raw("·") }}
|
||||
<i18n-t keypath="oncall.escalationAckedBy" tag="span" scope="global">
|
||||
<template #who>
|
||||
<OnCallPersonName
|
||||
:email="response.acked_by"
|
||||
data-test="oncall-response-acked-by-name"
|
||||
/>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</OTruncatedText>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- The two facts a responder needs before anything else ride the title,
|
||||
@@ -65,13 +83,18 @@
|
||||
<template #title-trail>
|
||||
<!-- Below lg the title row has room for the alert's name or its tags, not both; there the tags lead the body instead. -->
|
||||
<template v-if="response && lgUp">
|
||||
<span class="inline-flex" data-test="oncall-response-state-tag">
|
||||
<!-- The title is the one that ellipsises; the state facts beside it never shrink under it. -->
|
||||
<span class="inline-flex shrink-0" data-test="oncall-response-state-tag">
|
||||
<OTag type="oncallResponseState" :value="response.state" size="sm" />
|
||||
<OTooltip v-if="closedAfter" :content="closedAfter" />
|
||||
</span>
|
||||
<OTag type="alertPriority" :value="`p${response.priority}`" size="sm" />
|
||||
<!-- A closed page's state tag already says it; the phrase is only news while the ladder runs. -->
|
||||
<span v-if="isOpenState" data-test="oncall-response-phrase">
|
||||
<OTag class="shrink-0" type="alertPriority" :value="`p${response.priority}`" size="sm" />
|
||||
<!-- A closed page's state tag already says it, and an acknowledged one names its holder in the subtitle. -->
|
||||
<span
|
||||
v-if="isOpenState && response.state !== 'acknowledged'"
|
||||
class="shrink-0"
|
||||
data-test="oncall-response-phrase"
|
||||
>
|
||||
<OnCallEscalationCell
|
||||
:response-id="response.id"
|
||||
:record="response"
|
||||
@@ -85,6 +108,7 @@
|
||||
state is said rather than halfway down the page. -->
|
||||
<OTag
|
||||
v-if="isImpacted"
|
||||
class="shrink-0"
|
||||
variant="info-outline"
|
||||
size="sm"
|
||||
data-test="oncall-response-liaison-tag"
|
||||
@@ -154,8 +178,8 @@
|
||||
data-test="oncall-response-ack-btn"
|
||||
@click="acknowledgeRecord"
|
||||
>
|
||||
<OIcon name="check-circle" size="sm" />{{ t("oncall.acknowledge") }}
|
||||
<OTooltip :content="ackWords.ackHint" />
|
||||
<OIcon name="check-circle" size="sm" />{{ t("oncall.acknowledge") }}
|
||||
</OButton>
|
||||
<template v-if="response.state === 'acknowledged'">
|
||||
<OButton
|
||||
@@ -166,18 +190,24 @@
|
||||
data-test="oncall-response-take-over-btn"
|
||||
@click="takeOverRecord"
|
||||
>
|
||||
<OIcon name="how-to-reg" size="sm" />{{ t("oncall.takeOver") }}
|
||||
<OTooltip :content="t('oncall.takeOverHint')" />
|
||||
<OIcon name="how-to-reg" size="sm" />{{ t("oncall.takeOver") }}
|
||||
</OButton>
|
||||
<OButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:loading="acking"
|
||||
:disabled="!!unackBlockedReason"
|
||||
:focusable-unavailable="!!unackBlockedReason"
|
||||
data-test="oncall-response-unack-btn"
|
||||
@click="unacknowledgeRecord"
|
||||
>
|
||||
<!-- First child, so it anchors to the whole button: a disabled one must say why on hover. -->
|
||||
<OTooltip :content="unackBlockedReason ?? ackWords.unackHint" />
|
||||
<OIcon name="undo" size="sm" />{{ t("oncall.unacknowledge") }}
|
||||
<OTooltip :content="ackWords.unackHint" />
|
||||
<template v-if="unackBlockedReason" #unavailable-reason>{{
|
||||
unackBlockedReason
|
||||
}}</template>
|
||||
</OButton>
|
||||
</template>
|
||||
|
||||
@@ -464,7 +494,7 @@
|
||||
<OTabPanels v-model="activeDetailTab">
|
||||
<OTabPanel name="activity">
|
||||
<div data-test="oncall-response-activity">
|
||||
<div class="flex justify-end pt-2">
|
||||
<div v-if="systemActivityCount" class="flex justify-end pt-2">
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -804,6 +834,7 @@ import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import OnCallAboutPage from "@/components/oncall/OnCallAboutPage.vue";
|
||||
import OnCallDeliveryLedger from "@/components/oncall/OnCallDeliveryLedger.vue";
|
||||
import OnCallEscalationCell from "@/components/oncall/OnCallEscalationCell.vue";
|
||||
import OnCallPersonName from "@/components/oncall/OnCallPersonName.vue";
|
||||
import OnCallReachAlarm from "@/components/oncall/OnCallReachAlarm.vue";
|
||||
import OnCallWhatFired from "@/components/oncall/OnCallWhatFired.vue";
|
||||
import OnCallWhoIsOn from "@/components/oncall/OnCallWhoIsOn.vue";
|
||||
@@ -913,7 +944,7 @@ import { formatTimestampInTimezone } from "@/utils/date";
|
||||
const HOLD_RECHECK_MS = 10_000;
|
||||
|
||||
const { t } = useI18nTyped();
|
||||
const { nameOf } = useOrgUserNames();
|
||||
const { nameOf, tooltipOf } = useOrgUserNames();
|
||||
const { lgUp } = useBreakpoint();
|
||||
const nowMicros = useOnCallClock();
|
||||
const store = useStore();
|
||||
@@ -1036,7 +1067,8 @@ const showHandoff = ref(false);
|
||||
const escalatingNow = ref(false);
|
||||
|
||||
const noteBody = ref("");
|
||||
const showAllActivity = ref(true);
|
||||
/// Engine bookkeeping stays folded until asked for; the default feed is what people and the ladder did.
|
||||
const showAllActivity = ref(false);
|
||||
/// Activity, deliveries and prior causes as tabs of one card rather than a
|
||||
/// card plus two collapsibles — the toggle-all button in the header only
|
||||
/// makes sense while activity is the one showing.
|
||||
@@ -1060,6 +1092,13 @@ const subjectAlert = ref<{
|
||||
query_condition?: any;
|
||||
condition?: any;
|
||||
} | null>(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<I18nText | null>(null);
|
||||
const unackBlockedReason = computed<I18nText | null>(() =>
|
||||
subjectAlertMissing.value ? t("oncall.unackAlertDeleted") : unackRefusedReason.value,
|
||||
);
|
||||
const escalation = ref<EscalationProgress | null>(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;
|
||||
|
||||
@@ -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<string, unknown> = {}, 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");
|
||||
|
||||
@@ -21,6 +21,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="t('oncall.responsesTitle')"
|
||||
icon="notifications-active"
|
||||
>
|
||||
<template #title-trail>
|
||||
<BetaBadge class="shrink-0" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<!-- Whether the reader is themselves on call, beside the buttons rather
|
||||
than in the body: it qualifies the whole screen, not one section. -->
|
||||
@@ -708,13 +711,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
>
|
||||
{{ t("oncall.resolve") }}
|
||||
</ODropdownItem>
|
||||
<ODropdownItem
|
||||
icon-left="open-in-new"
|
||||
:data-test="`oncall-row-open-${row.rowKey}`"
|
||||
@select="openResponse(row)"
|
||||
>
|
||||
{{ t("oncall.rowOpenPage") }}
|
||||
</ODropdownItem>
|
||||
<ODropdownItem
|
||||
icon-left="timeline"
|
||||
:data-test="`oncall-row-show-timeline-${row.rowKey}`"
|
||||
@@ -828,6 +824,7 @@ import OnCallShiftBanner from "@/components/oncall/OnCallShiftBanner.vue";
|
||||
import { useOnCallPermissions } from "@/composables/useOnCallPermissions";
|
||||
import OText from "@/lib/core/Typography/OText.vue";
|
||||
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
|
||||
import BetaBadge from "@/components/common/BetaBadge.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useListViewState } from "@/composables/useListViewState";
|
||||
|
||||
@@ -259,6 +259,17 @@ describe("OnCallRouting", () => {
|
||||
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 () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
icon="alt-route"
|
||||
>
|
||||
<template #title-trail>
|
||||
<BetaBadge class="shrink-0" />
|
||||
<OIcon
|
||||
name="info-outline"
|
||||
size="sm"
|
||||
@@ -383,6 +384,7 @@ import type { RuleDraft } from "@/components/oncall/OnCallRuleEditor.vue";
|
||||
import type { SimulatorQuery } from "@/components/oncall/OnCallRoutingSimulator.vue";
|
||||
import OnCallUnroutedQueue from "@/components/oncall/OnCallUnroutedQueue.vue";
|
||||
import type { UnroutedFilters } from "@/components/oncall/OnCallUnroutedQueue.vue";
|
||||
import BetaBadge from "@/components/common/BetaBadge.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import useBreakpoint from "@/composables/useBreakpoint";
|
||||
import { useOnCallPermissions } from "@/composables/useOnCallPermissions";
|
||||
|
||||
@@ -169,6 +169,17 @@ describe("OnCallTeams", () => {
|
||||
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.
|
||||
|
||||
@@ -21,6 +21,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="t('oncall.teamsTitle')"
|
||||
icon="group-work"
|
||||
>
|
||||
<template #title-trail>
|
||||
<BetaBadge class="shrink-0" />
|
||||
</template>
|
||||
<template #actions>
|
||||
<OButton
|
||||
v-if="canConfigure"
|
||||
@@ -309,6 +312,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useStore } from "vuex";
|
||||
|
||||
import BetaBadge from "@/components/common/BetaBadge.vue";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
|
||||
import type { EmptyStateAction } from "@/lib/core/EmptyState/presets";
|
||||
|
||||
Reference in New Issue
Block a user