Merge branch 'fix/oncall-ux-pages' into fix/oncall-ux-polish

# Conflicts:
#	web/src/views/OnCall/OnCallResponseDetail.vue
This commit is contained in:
ktx-abhay
2026-11-04 12:50:54 +05:30
27 changed files with 502 additions and 94 deletions
@@ -162,4 +162,39 @@ describe("OnCallActivityTimeline", () => {
expect(text).toContain("14:37");
expect(text).toContain("14:39 · +2m");
});
/// The agent's paragraph is held to three lines with a way to read the rest; generated lines are not.
it("clamps the AI verdict line and leaves the other lines whole", async () => {
const verdict: OnCallResponseEvent = {
kind: "ai_verdict",
at: OPENED + 300_000_000,
actor: "o2-sre",
body: "The alert fired because the test stream carries synthetic data. ".repeat(8),
};
const wrapper = mount(OnCallActivityTimeline, {
props: { events: [...events, verdict], commentText: "", openedAt: OPENED },
global: { plugins: [i18n, store] },
});
await flushPromises();
const clamped = wrapper.findAll('[data-test="oncall-clamped-text-body"]');
expect(clamped).toHaveLength(1);
expect(clamped[0].text()).toContain("synthetic data");
expect(clamped[0].classes()).toContain("line-clamp-3");
});
/// The page scrolls past a long feed, so the note box stays stuck to its foot rather than below the fold.
it("sticks the note box to the page's foot when pinned", async () => {
const wrapper = mount(OnCallActivityTimeline, {
props: { events, commentText: "", openedAt: OPENED, pinComposer: true },
global: { plugins: [i18n, store] },
});
await flushPromises();
const composer = wrapper
.find('[data-test="oncall-response-note-input"]')
.element.closest(".pt-2");
expect(composer?.className).toContain("sticky");
expect(composer?.className).toContain("bottom-0");
expect(composer?.className).toContain("bg-surface-base");
});
});
@@ -32,8 +32,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@pointerout="onPointerOut"
>
<ActivityTimeline
ref="timelineRef"
:class="pinComposer ? 'h-[min(40rem,70vh)]!' : ''"
:composer-class="pinComposer ? 'bg-surface-base sticky bottom-0 z-10 pb-2' : ''"
:events="visibleEvents"
:submitting="submitting"
:show-scroll-buttons="showScrollButtons"
@@ -62,7 +61,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test-comment-input="oncall-response-note-input"
data-test-comment-send="oncall-response-note-submit"
@submit="$emit('submit')"
/>
>
<template #inline-text="{ event, html }">
<OnCallClampedText v-if="CLAMPED_KINDS.includes(event.kind)" :html="html" />
<span v-else class="text-text-body text-sm" v-html="html" />
</template>
</ActivityTimeline>
<OTooltip
:anchor="personAnchor"
:open="!!personAnchor"
@@ -73,11 +77,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch } from "vue";
import { computed, ref } from "vue";
import DOMPurify from "dompurify";
import { useStore } from "vuex";
import ActivityTimeline from "@/components/shared/ActivityTimeline.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OnCallClampedText from "@/components/oncall/OnCallClampedText.vue";
import { useOnCallClock } from "@/composables/useOnCallClock";
import { useOrgUserNames } from "@/composables/useOrgUserNames";
import { raw, useI18nTyped, type I18nText, type TranslateFn } from "@/types/i18n";
@@ -98,7 +103,7 @@ interface Props {
showScrollButtons?: boolean;
/** Micros the page opened at, for the "+2m" offset beside each clock time. */
openedAt?: number | null;
/** Fixes the feed's height so the note box sits at the panel's foot with the newest event above it. */
/** Keeps the note box stuck to the scrolling page's foot while the feed runs past it. */
pinComposer?: boolean;
/** The policy step a rung delay belongs to, or null when the policy no longer has it. */
stepOf?: (rungMicros: number) => number | null;
@@ -139,6 +144,8 @@ const { nameOf } = useOrgUserNames();
const currentUserId = computed(() => nameOf(String(store.state.userInfo?.email ?? "")) || "User");
const HUMAN_KINDS: ResponseEventKind[] = ["ack", "handoff", "note"];
/// The agent's free text runs to a paragraph; every other line is one generated sentence.
const CLAMPED_KINDS: ResponseEventKind[] = ["ai_verdict", "rca"];
const visibleEvents = computed(() => {
const merged = mergeTimelineEvents(props.events);
@@ -154,19 +161,6 @@ const getAvatarColor = getActivityAvatarColor;
const formatRelativeTime = (at: number): string =>
timelineStamp(at, props.openedAt, nowMicros.value, store.state.timezone);
const timelineRef = ref<{ scrollToBottom: () => void } | null>(null);
// Chat order: the newest line sits just above the note box, so a pinned feed opens scrolled to it.
watch(
() => visibleEvents.value.length,
async () => {
if (!props.pinComposer) return;
await nextTick();
timelineRef.value?.scrollToBottom();
},
{ immediate: true },
);
const getEventIcon = (event: OnCallResponseEvent): string => {
switch (event.kind) {
case "page":
@@ -0,0 +1,73 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { flushPromises, mount } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import OnCallClampedText from "@/components/oncall/OnCallClampedText.vue";
import i18n from "@/locales";
const proto = HTMLElement.prototype;
const original = {
scrollHeight: Object.getOwnPropertyDescriptor(proto, "scrollHeight"),
clientHeight: Object.getOwnPropertyDescriptor(proto, "clientHeight"),
};
function sized(scrollHeight: number, clientHeight: number) {
Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => scrollHeight });
Object.defineProperty(proto, "clientHeight", { configurable: true, get: () => clientHeight });
}
async function render() {
const wrapper = mount(OnCallClampedText, {
props: { html: "The alert fired because <b>the stream</b> is synthetic." },
global: { plugins: [i18n] },
});
await flushPromises();
return wrapper;
}
describe("OnCallClampedText", () => {
afterEach(() => {
for (const [key, desc] of Object.entries(original)) {
if (desc) Object.defineProperty(proto, key, desc);
}
});
it("offers no toggle when the text fits in three lines", async () => {
sized(60, 60);
const wrapper = await render();
expect(wrapper.find('[data-test="oncall-clamped-text-toggle"]').exists()).toBe(false);
expect(wrapper.find('[data-test="oncall-clamped-text-body"]').classes()).toContain(
"line-clamp-3",
);
});
it("opens the full text and closes it again", async () => {
sized(200, 60);
const wrapper = await render();
const toggle = () => wrapper.find('[data-test="oncall-clamped-text-toggle"]');
const body = () => wrapper.find('[data-test="oncall-clamped-text-body"]');
expect(toggle().text()).toBe("Show more");
await toggle().trigger("click");
expect(body().classes()).not.toContain("line-clamp-3");
expect(toggle().text()).toBe("Show less");
await toggle().trigger("click");
expect(body().classes()).toContain("line-clamp-3");
expect(body().html()).toContain("<b>the stream</b>");
});
});
@@ -0,0 +1,80 @@
<!-- Copyright 2026 OpenObserve Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
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/>.
-->
<!-- A long timeline line held to three lines, with the rest one click away. -->
<template>
<span class="flex basis-full flex-col items-start gap-0.5" data-test="oncall-clamped-text">
<span
ref="textRef"
class="text-text-body text-sm"
:class="expanded ? '' : 'line-clamp-3'"
data-test="oncall-clamped-text-body"
v-html="html"
/>
<OButton
v-if="overflows || expanded"
variant="ghost"
size="xs"
:icon-left="expanded ? 'expand-less' : 'expand-more'"
data-test="oncall-clamped-text-toggle"
@click="expanded = !expanded"
>
{{ expanded ? t("oncall.activityShowLess") : t("oncall.activityShowMore") }}
</OButton>
</span>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import OButton from "@/lib/core/Button/OButton.vue";
import { useI18nTyped } from "@/types/i18n";
const props = defineProps<{
/** Already sanitised markup. */
html: string;
}>();
const { t } = useI18nTyped();
const textRef = ref<HTMLElement | null>(null);
const expanded = ref(false);
const overflows = ref(false);
/// Measured only while clamped: an expanded line always fits, which would hide the way back.
function measure() {
const el = textRef.value;
if (!el || expanded.value) return;
overflows.value = el.scrollHeight > el.clientHeight + 1;
}
let observer: ResizeObserver | null = null;
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && textRef.value) {
observer = new ResizeObserver(measure);
observer.observe(textRef.value);
}
});
onBeforeUnmount(() => observer?.disconnect());
watch(
() => props.html,
async () => {
await nextTick();
measure();
},
);
defineExpose({ measure });
</script>
@@ -592,6 +592,97 @@ describe("OnCallMyDeliveries", () => {
);
});
describe("needs you pager", () => {
const open = (n: number) =>
Array.from({ length: n }, (_, i) =>
delivery({ event_id: `ev_${i}`, response_id: `resp_${i}`, at: AT + i }),
);
const needsYou = (wrapper: ReturnType<typeof render>) =>
wrapper.findComponent('[data-test="oncall-my-needs-you-table"]');
it("hides the pager while every open page fits", async () => {
answer(open(20));
const wrapper = render();
await flushPromises();
expect(needsYou(wrapper).attributes("pagination")).toBe("none");
});
it("pages the open pages once they outgrow one page", async () => {
answer(open(21));
const wrapper = render();
await flushPromises();
expect(needsYou(wrapper).attributes("pagination")).toBe("client");
expect(needsYou(wrapper).props("pageSize")).toBe(20);
});
it("keeps the chosen page size for the next visit", async () => {
answer(open(21));
const wrapper = render();
await flushPromises();
needsYou(wrapper).vm.$emit("update:pageSize", 50);
await flushPromises();
const saved = useListViewState("oncall-my-deliveries", {}) as Record<string, unknown>;
expect(saved.openPageSize).toBe(50);
});
it("pages through the open pages in the real table", async () => {
answer(open(25));
const { OTable: _table, ...rest } = stubs;
const wrapper = mount(OnCallMyDeliveries, {
global: { plugins: [i18n, store], stubs: rest },
});
await flushPromises();
const card = wrapper.find('[data-test="oncall-my-needs-you"]');
expect(card.find('[data-test="o2-table-pagination-bottom"]').exists()).toBe(true);
expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/1\D+20\D+25/);
await card.find('[data-test="o2-table-next-page-btn"]').trigger("click");
await flushPromises();
expect(card.find('[data-test="o2-table-pagination-info"]').text()).toMatch(/21\D+25\D+25/);
});
});
describe("history pager in the real table", () => {
const sent = (n: number) =>
Array.from({ length: n }, (_, i) =>
delivery({
event_id: `ev_${i}`,
response_id: `resp_${i}`,
title: `alert ${i}`,
response_state: "resolved",
at: AT + i,
}),
);
const { OTable: _table, ...realTable } = stubs;
const card = (wrapper: ReturnType<typeof render>) =>
wrapper.find('[data-test="oncall-my-history"]');
const rendered = (wrapper: ReturnType<typeof render>) =>
card(wrapper).findAll('[data-test^="o2-table-row-"]:not([data-test$="drag-handle"])');
/// The pager arrives with the rows, so the table must not keep the row model it was created with.
it("renders one page of rows that matches the footer", async () => {
Object.assign(useListViewState("oncall-my-deliveries", {}), { pageSize: 10 });
answer(sent(25));
const wrapper = mount(OnCallMyDeliveries, {
global: { plugins: [i18n, store], stubs: realTable },
});
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(rendered(wrapper)).toHaveLength(10);
const info = () => card(wrapper).find('[data-test="o2-table-pagination-info"]').text();
expect(info()).toMatch(/1\D+10\D+25/);
await card(wrapper).find('[data-test="o2-table-next-page-btn"]').trigger("click");
await flushPromises();
expect(rendered(wrapper)).toHaveLength(10);
expect(info()).toMatch(/11\D+20\D+25/);
});
});
describe("list view state", () => {
const defaults = { unreadOnly: false, currentPage: 1, pageSize: 20 };
@@ -16,7 +16,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- What has been sent to the reader: the open pages that still need them, then the history grouped by alert. -->
<template>
<div class="flex flex-col gap-5" data-test="oncall-my-deliveries">
<div
class="flex min-h-0 flex-col gap-5 max-xl:flex-none xl:flex-1"
data-test="oncall-my-deliveries"
>
<OEmptyState
v-if="loadError !== null"
size="inline"
@@ -31,22 +34,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template v-else>
<section
v-if="openRows.length"
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border xl:max-h-1/2 xl:min-h-0"
data-test="oncall-my-needs-you"
>
<div class="flex min-h-12 items-center px-4 py-2">
<div class="flex min-h-12 shrink-0 items-center px-4 py-2">
<OText variant="card-title" as="h2">{{ t("oncall.myNeedsYouTitle") }}</OText>
</div>
<!-- Basis auto, not 0: the card is sized by its rows up to its cap, so a zero basis would collapse it. -->
<!-- Keyed on the mode: OTable fixes its pagination row model when it is created, so a flip needs a fresh table. -->
<OTable
:key="openPaged ? 'paged' : 'flat'"
class="xl:min-h-0 xl:flex-auto"
:data="openRows"
:columns="openColumns"
row-key="response_id"
:frame="false"
:show-global-filter="false"
pagination="none"
:pagination="openPaged ? 'client' : 'none'"
:page-size="openPageSize"
:current-page="openPage"
table-id="oncall-my-needs-you"
data-test="oncall-my-needs-you-table"
@row-click="openPage"
@row-click="openResponse"
@update:current-page="openPage = $event"
@update:page-size="openPageSize = $event"
>
<template #cell-priority="{ row }">
<OTag type="alertPriority" :value="`p${row.priority}`" size="sm" />
@@ -95,10 +106,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</section>
<section
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border xl:min-h-0 xl:flex-1"
data-test="oncall-my-history"
>
<div class="flex min-h-12 flex-wrap items-center gap-2 px-4 py-2">
<div class="flex min-h-12 shrink-0 flex-wrap items-center gap-2 px-4 py-2">
<OText variant="card-title" as="h2">{{ t("oncall.myHistoryTitle") }}</OText>
<OTag
v-if="unread"
@@ -137,19 +148,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OTable
v-if="historyRows.length || loading"
ref="oTableRef"
:key="historyPaged ? 'paged' : 'flat'"
class="xl:min-h-0 xl:flex-1"
:data="historyRows"
:columns="historyColumns"
row-key="key"
:frame="false"
:loading="loading"
:skeleton-rows="HISTORY_SKELETON_ROWS"
:pagination="historyRows.length > pageSize ? 'client' : 'none'"
:pagination="historyPaged ? 'client' : 'none'"
:page-size="pageSize"
:current-page="currentPage"
:show-global-filter="false"
table-id="oncall-my-deliveries"
data-test="oncall-my-deliveries-table"
@row-click="(row: HistoryRow) => openPage(row.latest)"
@row-click="(row: HistoryRow) => openResponse(row.latest)"
@update:current-page="currentPage = $event"
@update:page-size="pageSize = $event"
>
@@ -336,6 +349,8 @@ 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;
/// OTable's smallest page-size option.
const SMALLEST_PAGE = 20;
const CHANNEL_ICON: Record<Channel, IconName> = {
email: "mail",
@@ -372,20 +387,24 @@ const savedView = useListViewState("oncall-my-deliveries", {
unreadOnly: false,
currentPage: 1,
pageSize: 20,
openPageSize: 20,
});
const unreadOnly = ref(savedView.unreadOnly);
const currentPage = ref(savedView.currentPage);
const pageSize = ref(savedView.pageSize);
const oTableRef = ref<any>(null);
const openPage = ref(1);
const openPageSize = ref(savedView.openPageSize);
watch(unreadOnly, () => {
currentPage.value = 1;
});
watch([unreadOnly, currentPage, pageSize], () => {
watch([unreadOnly, currentPage, pageSize, openPageSize], () => {
Object.assign(savedView, {
unreadOnly: unreadOnly.value,
currentPage: currentPage.value,
pageSize: pageSize.value,
openPageSize: openPageSize.value,
});
});
// The inbox loads after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
@@ -465,6 +484,11 @@ const historyRows = computed<HistoryRow[]>(() => {
.sort((a, b) => b.latest.at - a.latest.at);
});
/// The pager shows once a second page exists, and stays while a larger size is chosen so it can be undone.
const paged = (rows: number, size: number) => rows > Math.min(size, SMALLEST_PAGE);
const openPaged = computed(() => paged(openRows.value.length, openPageSize.value));
const historyPaged = computed(() => paged(historyRows.value.length, pageSize.value));
const openColumns = computed<OTableColumnDef<MyDelivery>[]>(() => [
{ id: "priority", header: t("oncall.priority"), size: 72, sortable: false },
{
@@ -627,7 +651,7 @@ async function acknowledge(row: MyDelivery) {
}
}
function openPage(row: MyDelivery) {
function openResponse(row: MyDelivery) {
router.push({
name: "onCallResponseDetail",
params: { responseId: row.response_id },
+11 -6
View File
@@ -114,11 +114,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</span>
<span
class="text-sm"
:class="'text-text-body'"
v-html="getInlineHtml(event)"
></span>
<slot name="inline-text" :event="event" :html="getInlineHtml(event)">
<span
class="text-sm"
:class="'text-text-body'"
v-html="getInlineHtml(event)"
></span>
</slot>
<span
v-if="getEventLayout(event) === 'after'"
@@ -185,7 +187,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Comment Input -->
<div class="pt-2">
<div class="pt-2" :class="composerClass">
<div class="flex gap-3">
<div class="flex-shrink-0 pt-1">
<div
@@ -276,6 +278,8 @@ interface Props {
dataTestScrollBottom: string;
dataTestCommentInput: string;
dataTestCommentSend: string;
/** Extra classes on the note box, e.g. to keep it stuck to a scrolling page's foot. */
composerClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -286,6 +290,7 @@ const props = withDefaults(defineProps<Props>(), {
isAiLabelled: undefined,
aiSreBadgeText: () => raw(""),
getTooltip: undefined,
composerClass: "",
});
const emit = defineEmits<{
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "تمت إضافة الملاحظة",
"addNoteFailed": "تعذر إضافة الملاحظة",
"activitySendNote": "إرسال ملاحظة",
"activityShowMore": "عرض المزيد",
"activityShowLess": "عرض أقل",
"activityScrollToTop": "التمرير إلى الأعلى",
"activityScrollToBottom": "التمرير إلى الأسفل",
"activityNotedPrefix": "دوّن",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Notiz hinzugefügt",
"addNoteFailed": "Die Notiz konnte nicht hinzugefügt werden.",
"activitySendNote": "Notiz senden",
"activityShowMore": "Mehr anzeigen",
"activityShowLess": "Weniger anzeigen",
"activityScrollToTop": "Nach oben scrollen",
"activityScrollToBottom": "Nach unten scrollen",
"activityNotedPrefix": "notiert",
+2
View File
@@ -26167,6 +26167,8 @@
"noteAdded": "Note added",
"addNoteFailed": "Could not add the note",
"activitySendNote": "Send note",
"activityShowMore": "Show more",
"activityShowLess": "Show less",
"activityScrollToTop": "Scroll to top",
"activityScrollToBottom": "Scroll to bottom",
"activityNotedPrefix": "noted",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Nota añadida",
"addNoteFailed": "No se pudo añadir la nota",
"activitySendNote": "Enviar nota",
"activityShowMore": "Mostrar más",
"activityShowLess": "Mostrar menos",
"activityScrollToTop": "Ir al principio",
"activityScrollToBottom": "Ir al final",
"activityNotedPrefix": "anotó",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Note ajoutée",
"addNoteFailed": "Impossible d'ajouter la note",
"activitySendNote": "Envoyer la note",
"activityShowMore": "Afficher plus",
"activityShowLess": "Afficher moins",
"activityScrollToTop": "Remonter en haut",
"activityScrollToBottom": "Descendre en bas",
"activityNotedPrefix": "a noté",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Nota aggiunta",
"addNoteFailed": "Impossibile aggiungere la nota",
"activitySendNote": "Invia nota",
"activityShowMore": "Mostra di più",
"activityShowLess": "Mostra di meno",
"activityScrollToTop": "Scorri in alto",
"activityScrollToBottom": "Scorri in basso",
"activityNotedPrefix": "ha annotato",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "メモを追加しました",
"addNoteFailed": "メモを追加できませんでした",
"activitySendNote": "メモを送信",
"activityShowMore": "さらに表示",
"activityShowLess": "表示を減らす",
"activityScrollToTop": "最上部へスクロール",
"activityScrollToBottom": "最下部へスクロール",
"activityNotedPrefix": "メモを追加しました",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "메모가 추가되었습니다",
"addNoteFailed": "메모를 추가하지 못했습니다",
"activitySendNote": "메모 보내기",
"activityShowMore": "더 보기",
"activityShowLess": "간단히 보기",
"activityScrollToTop": "맨 위로 이동",
"activityScrollToBottom": "맨 아래로 이동",
"activityNotedPrefix": "메모 남김",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Notitie toegevoegd",
"addNoteFailed": "Kan de notitie niet toevoegen",
"activitySendNote": "Notitie verzenden",
"activityShowMore": "Meer weergeven",
"activityShowLess": "Minder weergeven",
"activityScrollToTop": "Scroll naar boven",
"activityScrollToBottom": "Scroll naar beneden",
"activityNotedPrefix": "noteerde",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Dodano notatkę",
"addNoteFailed": "Nie udało się dodać notatki",
"activitySendNote": "Wyślij notatkę",
"activityShowMore": "Pokaż więcej",
"activityShowLess": "Pokaż mniej",
"activityScrollToTop": "Przewiń do góry",
"activityScrollToBottom": "Przewiń na dół",
"activityNotedPrefix": "dodał notatkę",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Nota adicionada",
"addNoteFailed": "Não foi possível adicionar a nota",
"activitySendNote": "Enviar nota",
"activityShowMore": "Mostrar mais",
"activityShowLess": "Mostrar menos",
"activityScrollToTop": "Deslocar para o topo",
"activityScrollToBottom": "Deslocar para o fim",
"activityNotedPrefix": "anotou",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Заметка добавлена",
"addNoteFailed": "Не удалось добавить заметку",
"activitySendNote": "Отправить заметку",
"activityShowMore": "Показать больше",
"activityShowLess": "Показать меньше",
"activityScrollToTop": "Прокрутить вверх",
"activityScrollToBottom": "Прокрутить вниз",
"activityNotedPrefix": "отметил",
+2
View File
@@ -26080,6 +26080,8 @@
"noteAdded": "Note added",
"addNoteFailed": "Could not add the note",
"activitySendNote": "Send note",
"activityShowMore": "แสดงเพิ่มเติม",
"activityShowLess": "แสดงน้อยลง",
"activityScrollToTop": "Scroll to top",
"activityScrollToBottom": "Scroll to bottom",
"activityNotedPrefix": "noted",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Not eklendi",
"addNoteFailed": "Not eklenemedi",
"activitySendNote": "Notu gönder",
"activityShowMore": "Daha fazla göster",
"activityShowLess": "Daha az göster",
"activityScrollToTop": "En üste git",
"activityScrollToBottom": "En alta git",
"activityNotedPrefix": "not ekledi",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "Đã thêm ghi chú",
"addNoteFailed": "Không thể thêm ghi chú",
"activitySendNote": "Gửi ghi chú",
"activityShowMore": "Xem thêm",
"activityShowLess": "Thu gọn",
"activityScrollToTop": "Cuộn lên đầu",
"activityScrollToBottom": "Cuộn xuống cuối",
"activityNotedPrefix": "đã ghi chú",
+2
View File
@@ -26082,6 +26082,8 @@
"noteAdded": "备注已添加",
"addNoteFailed": "无法添加备注",
"activitySendNote": "发送备注",
"activityShowMore": "显示更多",
"activityShowLess": "收起",
"activityScrollToTop": "滚动到顶部",
"activityScrollToBottom": "滚动到底部",
"activityNotedPrefix": "已备注",
+2
View File
@@ -26077,6 +26077,8 @@
"noteAdded": "備註已新增",
"addNoteFailed": "無法新增備註",
"activitySendNote": "傳送備註",
"activityShowMore": "顯示更多",
"activityShowLess": "收合",
"activityScrollToTop": "捲動至頂端",
"activityScrollToBottom": "捲動至底部",
"activityNotedPrefix": "已備註",
+10 -10
View File
@@ -16,13 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- The reader's own on-call: their shifts, how pages reach them, and what has been sent to them. -->
<template>
<OPageLayout
bleed
data-test="oncall-mine-page"
:title="t('oncall.mineTitle')"
icon="person"
scroll
>
<OPageLayout bleed data-test="oncall-mine-page" :title="t('oncall.mineTitle')" icon="person">
<template #title-trail>
<BetaBadge class="shrink-0" />
</template>
@@ -61,9 +55,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="oncall-mine-unavailable"
/>
<OContent v-else y class="flex flex-col gap-5">
<OnCallSetupBanner ref="setupRef" />
<div class="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
<!-- Below xl the two top cards stack and leave no room to share, so the body scrolls instead. -->
<OContent
v-else
y
class="flex min-h-0 flex-1 flex-col gap-5 max-xl:overflow-y-auto"
data-test="oncall-mine-body"
>
<OnCallSetupBanner ref="setupRef" class="shrink-0" />
<div class="grid shrink-0 grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
<!-- The shift first: "am I on call, and until when" is the question this page opens on. -->
<section
class="rounded-surface bg-surface-base border-border-default table-last-row-flush flex min-w-0 flex-col overflow-hidden border"
@@ -109,7 +109,7 @@ const stubs = {
// `title-trail` carries the state tags and `subtitle` the team/firing/
// opened line, both of which are assertions of their own.
template:
"<div><slot name='subtitle' /><slot name='title-trail' /><slot name='actions' /><div data-test='stub-header-tabs'><slot name='header-tabs' /></div><slot /></div>",
"<div><div data-test='stub-subtitle'><slot name='subtitle' /></div><div data-test='stub-title-trail'><slot name='title-trail' /></div><slot name='actions' /><div data-test='stub-header-tabs'><slot name='header-tabs' /></div><slot /></div>",
},
OTooltip: {
name: "OTooltip",
@@ -632,6 +632,39 @@ describe("OnCallResponseDetail", () => {
expect(acked.findComponent({ name: "OnCallPersonName" }).props("email")).toBe("ana@acme.io");
});
/// A long ladder line beside the title ran under the header actions, so it reads on the subtitle line.
it("says how far the page has climbed on the subtitle line, not beside the title", async () => {
const wrapper = await renderWith();
const phrase = wrapper.find('[data-test="stub-subtitle"] [data-test="oncall-response-phrase"]');
expect(phrase.exists()).toBe(true);
expect(phrase.classes()).toContain("min-w-0");
expect(
wrapper.find('[data-test="stub-title-trail"] [data-test="oncall-response-phrase"]').exists(),
).toBe(false);
});
/// The title row holds only state and priority; an exhausted ladder is said in words on the subtitle line.
it("says nobody answered on the subtitle line, not as a chip beside the title", async () => {
service.escalationProgress.mockResolvedValue({
data: {
fired: [{ after_micros: 0, at: 1, targets: ["ana@acme.io"], reached: ["ana@acme.io"] }],
next_targets: [],
next_at: null,
exhausted: true,
},
} as any);
const wrapper = await renderWith();
const words = wrapper.find(
'[data-test="stub-subtitle"] [data-test="oncall-response-phrase-exhausted"]',
);
expect(words.text()).toContain("Nobody answered");
const trail = wrapper.find('[data-test="stub-title-trail"]');
expect(trail.findAllComponents({ name: "OTag" })).toHaveLength(2);
expect(trail.find('[data-test="oncall-response-title-trail"]').classes()).toContain(
"overflow-hidden",
);
});
describe("an Unacknowledge the server cannot apply", () => {
const held = () => ({
state: "acknowledged",
@@ -1072,8 +1105,7 @@ describe("OnCallResponseDetail", () => {
});
const tag = wrapper.find('[data-test="oncall-response-liaison-tag"]');
const tooltip = tag.element.nextElementSibling;
expect(tooltip?.textContent).toContain("one chase");
expect(tag.text()).toContain("one chase");
});
it("leaves an owner record with no liaison language at all", async () => {
@@ -1604,14 +1636,15 @@ describe("OnCallResponseDetail — what the payload already knew", () => {
});
});
/// R13, L2: the phrase keeps a readable floor and truncates past it, and the header keeps the title's floor.
/// R13, L2: on the subtitle line the phrase gives way after team and time; in the body it keeps a readable floor; the title keeps its floor.
it("keeps the escalation phrase readable and the title floored", async () => {
for (const lgUp of [true, false]) {
mockViewport.lgUp = lgUp;
const wrapper = await renderWith();
const classes = wrapper.find('[data-test="oncall-response-phrase"]').classes();
expect(classes, `lg ${lgUp}`).toEqual(expect.arrayContaining(["min-w-32", "truncate"]));
expect(classes, `lg ${lgUp}`).not.toContain("min-w-0");
expect(classes, `lg ${lgUp}`).toEqual(
expect.arrayContaining(lgUp ? ["min-w-0", "shrink-[100]"] : ["min-w-32", "truncate"]),
);
expect(wrapper.findComponent({ name: "OPageLayout" }).attributes()).toHaveProperty(
"title-min-width",
);
@@ -1756,10 +1789,10 @@ describe("OnCallResponseDetail — responding", () => {
expect(
wrapper
.find(
'[data-test="oncall-response-phrase"] [data-test="oncall-escalation-cell-exhausted"]',
'[data-test="oncall-response-phrase"] [data-test="oncall-response-phrase-exhausted"]',
)
.exists(),
).toBe(true);
.text(),
).toContain("Nobody answered");
const banner = wrapper.find('[data-test="oncall-response-exhausted-banner"]');
expect(banner.text()).toMatch(
/^Nobody answered\. Every step ran by [A-Z][a-z]{2} \d{1,2}, \d{4}, \d{2}:\d{2}\. Acknowledge to take it, or hand it off\.$/,
+79 -40
View File
@@ -76,27 +76,25 @@
</template>
</i18n-t>
</OTruncatedText>
</span>
</template>
<!-- The two facts a responder needs before anything else ride the title,
rather than sitting in a metadata grid below the fold. -->
<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">
<!-- The state facts never shrink; the long paging phrase gives way first. -->
<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 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. -->
<!-- A closed page's state tag already says it, and an acknowledged one names its holder just before. -->
<span
v-if="isOpenState && response.state !== 'acknowledged'"
class="flex min-w-32 shrink-[1000] truncate"
v-if="lgUp && isOpenState && response.state !== 'acknowledged'"
class="flex min-w-0 shrink-[100] items-center gap-1"
data-test="oncall-response-phrase"
>
<span v-if="headerPhraseKind !== 'unknown'" class="shrink-0">{{ raw("·") }}</span>
<!-- Words, not the list's chip: the title row keeps only state and priority. -->
<OTruncatedText
v-if="headerPhraseKind === 'nobodyAnswered'"
class="text-status-error-text min-w-0"
:tooltip="t('oncall.ladderExhausted')"
data-test="oncall-response-phrase-exhausted"
>
{{ t("oncall.state_exhausted") }}
</OTruncatedText>
<OnCallEscalationCell
v-else
class="min-w-0"
:response-id="response.id"
:record="response"
:progress="escalation"
@@ -106,18 +104,34 @@
:team-deleted="teamDeleted"
/>
</span>
<!-- Why this team is looking at somebody else's outage, said where the
state is said rather than halfway down the page. -->
<OTag
v-if="isImpacted"
class="shrink-0"
variant="info-outline"
size="sm"
<!-- Why this team is looking at somebody else's outage, said with the page's other facts. -->
<span
v-if="lgUp && isImpacted"
class="text-status-info-text inline-flex shrink-[100] items-center gap-1 truncate"
data-test="oncall-response-liaison-tag"
>
{{ t("oncall.liaisonTag") }}
</OTag>
<OTooltip v-if="isImpacted" side="bottom" :content="t('oncall.ladderLiaisonNote')" />
{{ raw("·") }} {{ t("oncall.liaisonTag") }}
<OTooltip side="bottom" :content="t('oncall.ladderLiaisonNote')" />
</span>
</span>
</template>
<!-- The two facts a responder needs before anything else ride the title,
rather than sitting in a metadata grid below the fold. -->
<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">
<!-- The title ellipsises first; clipping here means the chips can never paint under the actions. -->
<span
class="flex min-w-0 shrink-[0.01] items-center gap-3.25 overflow-hidden"
data-test="oncall-response-title-trail"
>
<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 class="shrink-0" type="alertPriority" :value="`p${response.priority}`" size="sm" />
</span>
</template>
</template>
@@ -215,7 +229,7 @@
<!-- A menu of durations, not a panel that pushes the page down. -->
<OButton
v-if="canUnsnooze && lgUp"
v-if="canUnsnooze && inlineVerbs"
variant="outline"
size="sm"
:loading="snoozing"
@@ -225,7 +239,7 @@
<OTooltip :content="t('oncall.unsnoozeHint')" />
<OIcon name="notifications-active" size="sm" />{{ t("oncall.unsnooze") }}
</OButton>
<ODropdown v-else-if="canSnooze && lgUp">
<ODropdown v-else-if="canSnooze && inlineVerbs">
<template #trigger>
<OButton
variant="outline"
@@ -249,7 +263,7 @@
</ODropdown>
<!-- Not on an unrouted page: the server refuses it there, and Hand off is the next step. -->
<span v-if="lgUp && canEscalate" class="inline-flex">
<span v-if="inlineVerbs && canEscalate" class="inline-flex">
<OButton
variant="outline"
size="sm"
@@ -264,7 +278,7 @@
</span>
<OButton
v-if="lgUp"
v-if="inlineVerbs"
variant="outline"
size="sm"
data-test="oncall-response-handoff-btn"
@@ -281,7 +295,7 @@
something larger routinely arrives after it was closed. Hidden once
an incident exists, because the rail links to it from then on. -->
<OButton
v-if="response && !response.incident_id && lgUp"
v-if="response && !response.incident_id && inlineVerbs"
variant="outline"
size="sm"
data-test="oncall-response-promote-btn"
@@ -317,8 +331,8 @@
</OButton>
</template>
<!-- Below lg the row holds claiming and closing; the verbs used less often share one menu so the header stays two rows. -->
<ODropdown v-if="response && !lgUp && hasMoreActions" side="bottom" align="end">
<!-- Until the row has room beside a readable title it holds claiming and closing; the verbs used less often share one menu. -->
<ODropdown v-if="response && !inlineVerbs && hasMoreActions" side="bottom" align="end">
<template #trigger>
<OButton
variant="outline"
@@ -379,10 +393,7 @@
</template>
<template v-if="response">
<!-- OPageLayout keeps the body a fixed column (`scroll` off) so a table
can own its own scroller. This page is a document, so the body is
the scroller — without it the activity thread was clipped at the
fold with no way to reach the rest. -->
<!-- This page is a document, so the body is the scroller; the note box sticks to its foot. -->
<OContent y class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
v-if="!lgUp"
@@ -526,9 +537,11 @@
analysis panel that sits empty forever. -->
<OnCallVerdictCard :events="events" />
<OCard variant="glass" data-test="oncall-response-tabs">
<!-- Not clipped, or the note box could not stick to the scrolling body's foot. -->
<OCard variant="glass" class="overflow-visible" data-test="oncall-response-tabs">
<OCardSection role="body" dense>
<OTabPanels v-model="activeDetailTab">
<!-- Unclipped, or it becomes the note box's sticky container instead of the page. -->
<OTabPanels v-model="activeDetailTab" class="overflow-visible!">
<OTabPanel name="activity">
<div data-test="oncall-response-activity">
<div v-if="systemActivityCount" class="flex justify-end pt-2">
@@ -970,6 +983,7 @@ import {
isUnresolved,
formatOnCallInstant,
isDeletedTeam,
pagePhrase,
pageState,
promoteSeverityFloor,
promoteSeverityOptions,
@@ -982,10 +996,21 @@ import { formatTimestampInTimezone } from "@/utils/date";
const HOLD_RECHECK_MS = 10_000;
/// Re-reads of a lapsed rung before giving up: a minute at HOLD_RECHECK_MS.
const LAPSE_RECHECKS = 6;
// Every verb inline takes about 56rem, which below this leaves the title a few letters wide.
const INLINE_VERBS_QUERY = "(min-width: 96rem)";
const { t } = useI18nTyped();
const { nameOf, tooltipOf } = useOrgUserNames();
const { lgUp } = useBreakpoint();
const inlineVerbsMql =
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(INLINE_VERBS_QUERY)
: null;
const wideHeader = ref(inlineVerbsMql?.matches ?? true);
const onWideHeader = (e: MediaQueryListEvent) => (wideHeader.value = e.matches);
inlineVerbsMql?.addEventListener?.("change", onWideHeader);
onBeforeUnmount(() => inlineVerbsMql?.removeEventListener?.("change", onWideHeader));
const inlineVerbs = computed(() => lgUp.value && wideHeader.value);
const nowMicros = useOnCallClock();
const store = useStore();
const route = useRoute();
@@ -1258,6 +1283,20 @@ const headerReachedStep = computed(() =>
),
);
/// The subtitle's separator waits for a phrase, so an unanswered ladder leaves no dangling dot.
const headerPhraseKind = computed(() =>
response.value
? pagePhrase({
record: response.value,
progress: escalation.value,
progressLoaded: escalationLoaded.value,
totalRungs: totalRungs.value,
reachedStep: headerReachedStep.value,
nowMicros: nowMicros.value,
}).kind
: "unknown",
);
// The same progress decides the header tag; the time is the last step's, which the record's flag lags.
const exhaustedAtClock = computed(() => {
const r = response.value;