fix: incident list as seperate menu (#10080)

### **PR Type**
Enhancement


___

### **Description**
- Extract incidents into standalone page

- Remove incidents tab from alerts list

- Update router and menu navigation links

- Simplify search & layout in IncidentList.vue

- Add multilingual translations for incidents


___

### Diagram Walkthrough


```mermaid
flowchart LR
  ML["MainLayout"] -- "click Alerts" --> AL["AlertList.vue"]
  ML -- "click Incidents" --> IL["IncidentList.vue"]
  AL -- "imports IncidentList removed" --> IL
```



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>4
files</summary><table>
<tr>
<td><strong>router.ts</strong><dd><code>Add dedicated route for
IncidentList.vue</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-a1827956e5a817edc7efb5f4516e7cc4d453e15ea9e9d970aafb8360b09fc78e">+12/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>AlertList.vue</strong><dd><code>Remove incidents tab and
adjust layout</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-be3e41b823e163b226ffc270d95cc4981e59c600851e4e0d10ee5e4ee994ac8e">+14/-102</a></td>

</tr>

<tr>
<td><strong>IncidentList.vue</strong><dd><code>Refactor IncidentList
with header and search</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-953ad015996758a8c86e489499cd3b0a614501b99b0c9c5c242a299e34c24ff2">+54/-28</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>MainLayout.vue</strong><dd><code>Add incidents link to main
menu</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-91e4134141264c4624d4eb4cfa987b33ed31eb6f551e089c0b26f2610dd54362">+11/-4</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>11
files</summary><table>
<tr>
<td><strong>de.json</strong><dd><code>Add German translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-78f24ecb2dbcfc38fdf3ec22b2559a5b40d236ea689d1c55f5d3e87168310264">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>en.json</strong><dd><code>Add English translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-892aa3d75ab6e129ef9116aa21bec1b9a15ba69ce5366d2a19128078c690d824">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>es.json</strong><dd><code>Add Spanish translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-ac2a2167b8b973e3f776f9fe3b43bef4576470d8ad33f3d7cfaff6edff0e5778">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>fr.json</strong><dd><code>Add French translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-db80e762b88ddf183820c050cdc6b215e6b02b365b5e73f5152af46b0d26f1a2">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>it.json</strong><dd><code>Add Italian translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-2de78074a5f887afddce60d694ead9322e6ab223eb70251c13819f3028bdc70d">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>ja.json</strong><dd><code>Add Japanese translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-092e49c5f9fbc1b539ab9c2866d108d0ce1d314022b4c294eb4611b285e7bbd3">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>ko.json</strong><dd><code>Add Korean translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-50e27ca1b4825a5026f3a251bfcf79c97cab07cfade2ebd512d332453d707c48">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>nl.json</strong><dd><code>Add Dutch translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-a1706021687ae5d8a1c006c49eab3946174fecd895f9b70a89c2e76d97250906">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>pt.json</strong><dd><code>Add Portuguese translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-38e804f64cb5e0e451849fc84e9b46e53c954a4a4263d51c18665ad45f4b4815">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>tr.json</strong><dd><code>Add Turkish translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-6f0f131e9e2591480c4c5d1eaffc72d02dc31cbd8d3f90d8f012f08cabac8818">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>zh.json</strong><dd><code>Add Chinese translation for
incidents</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/openobserve/openobserve/pull/10080/files#diff-8818055db58f83230aeea4ebfd4cec31d924e8c47b6f91298e356959ab25339e">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Sai Nikhil
2026-01-19 07:08:45 +05:30
committed by GitHub
co-authored by github-actions[bot]
parent c3793677b5
commit 99c0221126
21 changed files with 511 additions and 461 deletions
+1 -3
View File
@@ -293,12 +293,10 @@ const waitData = async (wrapper: any) => {
// 1. Basic rendering and structure
describe("AlertList - basic rendering", () => {
it("renders the page container and view mode tabs", async () => {
it("renders the page container", async () => {
const wrapper = await mountAlertList();
await waitData(wrapper);
expect(wrapper.find('[data-test="alert-list-page"]').exists()).toBe(true);
// Verify view mode tabs exist (Alerts/Incidents tabs)
expect(wrapper.find('[data-test="alert-incident-view-tabs"]').exists()).toBe(true);
});
it("renders search input and toggle", async () => {
+14 -102
View File
@@ -21,33 +21,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-list-page"
class="q-pa-none flex flex-col"
>
<div class="tw:w-full tw:h-[68px] tw:px-[0.625rem] tw:mb-[0.375rem] q-pt-xs" v-if="!showAddAlertDialog && !showImportAlertDialog">
<div class="tw:w-full tw:px-[0.625rem] q-pt-xs" v-if="!showAddAlertDialog && !showImportAlertDialog">
<div class="card-container">
<div
class="flex justify-between full-width tw:py-3 tw:px-4 items-center"
class="flex justify-between full-width tw:py-3 tw:mb-[0.625rem] tw:px-4 tw:h-[68px] items-center"
>
<div class="tw:flex tw:items-center tw:gap-4">
<!-- View Mode Tabs (Alerts / Incidents) -->
<div class="flex justify-start">
<q-tabs v-model="viewMode" inline-label dense @update:model-value="onViewModeChange" data-test="alert-incident-view-tabs">
<q-tab
name="alerts"
:label="t('alerts.header')"
no-caps
data-test="alert-view-tab"
/>
<q-tab
name="incidents"
:label="t('alerts.incidents.title')"
no-caps
data-test="incident-view-tab"
/>
</q-tabs>
<div class="q-table__title tw:font-[600]" data-test="alert-list-title">
{{ t("alerts.header") }}
</div>
</div>
<div class="flex q-ml-auto tw:ps-2 items-center">
<!-- Tabs only visible in Alerts view -->
<div v-if="viewMode === 'alerts'" class="app-tabs-container tw:h-[36px] q-mr-sm">
<!-- Alert Tabs -->
<div class="app-tabs-container tw:h-[36px] q-mr-sm">
<app-tabs
class="tabs-selection-container"
:tabs="alertTabs"
@@ -55,9 +41,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:active-tab="filterAlertsByTab"
/>
</div>
<!-- Search for Alerts view -->
<!-- Search for Alerts -->
<q-input
v-if="viewMode === 'alerts'"
v-model="dynamicQueryModel"
dense
borderless
@@ -75,23 +60,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-icon class="o2-search-input-icon" name="search" />
</template>
</q-input>
<!-- Search for Incidents view -->
<q-input
v-if="viewMode === 'incidents'"
v-model="incidentSearchQuery"
dense
borderless
placeholder="Search incidents..."
data-test="incident-search-input"
clearable
class="o2-search-input"
>
<template #prepend>
<q-icon class="o2-search-input-icon" name="search" />
</template>
</q-input>
<!-- All Folders toggle (only for alerts view) -->
<div v-if="viewMode === 'alerts'" class="tw:ml-2">
<!-- All Folders toggle -->
<div class="tw:ml-2">
<q-toggle
data-test="alert-list-search-across-folders-toggle"
v-model="searchAcrossFolders"
@@ -119,9 +89,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
data-test="alert-insights-btn"
icon="insights"
/>
<!-- Import button (only for alerts view) -->
<!-- Import button -->
<q-btn
v-if="viewMode === 'alerts'"
class="q-ml-sm o2-secondary-button tw:h-[36px]"
no-caps
flat
@@ -129,9 +98,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@click="importAlert"
data-test="alert-import"
/>
<!-- Add Alert button (only for alerts view) -->
<!-- Add Alert button -->
<q-btn
v-if="viewMode === 'alerts'"
data-test="alert-list-add-alert-btn"
class="q-ml-sm o2-primary-button tw:h-[36px]"
no-caps
@@ -147,20 +115,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-if="!showAddAlertDialog && !showImportAlertDialog"
class="full-width alert-list-table"
style="height: calc(100vh - 110px)"
style="height: calc(100vh - 118px)"
>
<!-- Incidents View (no folders) -->
<div v-if="viewMode === 'incidents'" class="tw:w-full tw:h-full tw:pr-[0.625rem] tw:pb-[0.625rem]">
<IncidentList :searchQuery="incidentSearchQuery" />
</div>
<!-- Alerts View (with folders) -->
<q-splitter
v-else
v-model="splitterModel"
unit="px"
:limits="[200, 500]"
style="height: calc(100vh - 110px)"
style="height: calc(100vh - 118px)"
data-test="alert-list-splitter"
>
<template #before>
@@ -912,7 +874,6 @@ import AppTabs from "@/components/common/AppTabs.vue";
import SelectFolderDropDown from "../common/sidebar/SelectFolderDropDown.vue";
import AlertHistoryDrawer from "@/components/alerts/AlertHistoryDrawer.vue";
import { symOutlinedSoundSampler } from "@quasar/extras/material-symbols-outlined";
import IncidentList from "@/components/alerts/IncidentList.vue";
import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
import { buildConditionsString } from "@/utils/alerts/conditionsFormatter";
// import alertList from "./alerts";
@@ -933,7 +894,6 @@ export default defineComponent({
AppTabs,
SelectFolderDropDown,
AlertHistoryDrawer,
IncidentList,
O2AIContextAddBtn,
},
emits: [
@@ -1143,32 +1103,12 @@ export default defineComponent({
const activeFolderToMove = ref("default");
// Initialize viewMode from URL query parameter
const viewMode = ref(
(router.currentRoute.value.query.view as string) === "incidents" ? "incidents" : "alerts"
);
// Initialize activeTab from URL query parameter, default to "all"
const activeTab = ref(
(router.currentRoute.value.query.tab as string) || "all"
);
// Incident search query
const incidentSearchQuery = ref("");
// View mode tabs (Alerts / Incidents)
const viewTabs = computed(() => [
{
label: t('alerts.header'),
value: 'alerts',
},
{
label: t('alerts.incidents.title'),
value: 'incidents',
}
]);
// Tabs for alerts view only (removed incidents tab)
// Tabs for alerts view
const alertTabs = reactive([
{
label: t("alerts.all"),
@@ -1198,32 +1138,8 @@ export default defineComponent({
label: t("alerts.realTime"),
value: "realTime",
},
{
label: t("alerts.incidents.title"),
value: "incidents",
},
]);
const onViewModeChange = (newMode: string) => {
// Update viewMode immediately
viewMode.value = newMode;
// Update URL query parameter
router.push({
query: {
...router.currentRoute.value.query,
view: newMode,
// Reset tab to 'all' when switching to alerts view
tab: newMode === "alerts" ? "all" : undefined,
},
});
// Reset active tab when switching to alerts view
if (newMode === "alerts") {
activeTab.value = "all";
}
};
const columns = computed(() => {
const baseColumns: any = [
{
@@ -2781,10 +2697,6 @@ export default defineComponent({
computedOwner,
tabs,
alertTabs,
viewMode,
viewTabs,
onViewModeChange,
incidentSearchQuery,
filterAlertsByTab,
showHistoryDrawer,
selectedHistoryAlertId,
@@ -79,11 +79,14 @@ describe("IncidentDetailDrawer.vue", () => {
Object.assign(store.state, storeOverrides);
}
// Set incident_id in router query if provided
// Set incident ID in router params if provided (changed from query to params)
if (incidentId) {
await router.push({ query: { incident_id: incidentId } });
} else {
await router.push({ query: {} });
// Set route params directly to avoid async router.push hanging
router.currentRoute.value = {
...router.currentRoute.value,
params: { id: incidentId },
name: "incidentDetail",
} as any;
}
return mount(IncidentDetailDrawer, {
@@ -93,9 +96,10 @@ describe("IncidentDetailDrawer.vue", () => {
global: {
plugins: [i18n, store, router],
stubs: {
QDrawer: true,
QPage: true,
TelemetryCorrelationDashboard: true,
IncidentServiceGraph: true,
SREChat: true,
},
},
});
@@ -179,11 +183,13 @@ describe("IncidentDetailDrawer.vue", () => {
describe("URL-based Incident Loading", () => {
it("should emit close when drawer closes", async () => {
wrapper = await createWrapper();
const pushSpy = vi.spyOn(router, 'push');
wrapper.vm.close();
await nextTick();
expect(wrapper.emitted("close")).toBeTruthy();
// Should navigate back to incident list instead of emitting
expect(pushSpy).toHaveBeenCalledWith({ name: "incidentList" });
});
it("should load details when incident_id is in URL", async () => {
@@ -334,10 +340,13 @@ describe("IncidentDetailDrawer.vue", () => {
expect(mockNotify.mock.calls[0][0].type).toBe("positive");
});
it("should emit status-updated event", async () => {
it("should not emit status-updated event (removed)", async () => {
// This test is no longer relevant as the component doesn't emit events
// The component now handles its own state updates
await wrapper.vm.acknowledgeIncident();
expect(wrapper.emitted("status-updated")).toBeTruthy();
// Just verify the API was called
expect(incidentsService.updateStatus).toHaveBeenCalled();
});
it("should handle status update error", async () => {
@@ -482,35 +491,49 @@ describe("IncidentDetailDrawer.vue", () => {
await flushPromises();
});
it("should open SRE chat with incident context", () => {
wrapper.vm.openSREChat();
expect(store.state.sreChatContext).toBeTruthy();
expect(store.state.sreChatContext.type).toBe("incident");
});
it("should dispatch setIsSREChatOpen action", () => {
const dispatchSpy = vi.spyOn(store, 'dispatch');
it("should toggle SRE chat visibility", () => {
expect(wrapper.vm.showAIChat).toBe(false);
wrapper.vm.openSREChat();
expect(wrapper.vm.showAIChat).toBe(true);
expect(dispatchSpy).toHaveBeenCalledWith("setIsSREChatOpen", true);
});
it("should pass incident details to chat", () => {
wrapper.vm.openSREChat();
expect(store.state.sreChatContext.data.id).toBe("1");
expect(store.state.sreChatContext.data.severity).toBe("P1");
expect(wrapper.vm.showAIChat).toBe(false);
});
it("should include triggers in chat context", () => {
wrapper.vm.openSREChat();
it("should compute incident context data correctly", () => {
const contextData = wrapper.vm.incidentContextData;
expect(store.state.sreChatContext.data.triggers).toBeTruthy();
expect(contextData).toBeTruthy();
expect(contextData.id).toBe("1");
expect(contextData.severity).toBe("P1");
expect(contextData.status).toBe("open");
});
it("should include existing RCA in chat context", () => {
it("should include all required fields in context data", () => {
const contextData = wrapper.vm.incidentContextData;
expect(contextData).toHaveProperty("id");
expect(contextData).toHaveProperty("title");
expect(contextData).toHaveProperty("status");
expect(contextData).toHaveProperty("severity");
expect(contextData).toHaveProperty("alert_count");
expect(contextData).toHaveProperty("first_alert_at");
expect(contextData).toHaveProperty("last_alert_at");
expect(contextData).toHaveProperty("stable_dimensions");
expect(contextData).toHaveProperty("topology_context");
expect(contextData).toHaveProperty("triggers");
expect(contextData).toHaveProperty("rca_analysis");
});
it("should include triggers in context data", () => {
const contextData = wrapper.vm.incidentContextData;
expect(contextData.triggers).toBeTruthy();
expect(Array.isArray(contextData.triggers)).toBe(true);
});
it("should include existing RCA in context data when available", async () => {
wrapper.vm.incidentDetails.topology_context = {
service: "api",
upstream_services: [],
@@ -519,17 +542,20 @@ describe("IncidentDetailDrawer.vue", () => {
suggested_root_cause: "Existing RCA",
};
wrapper.vm.openSREChat();
await nextTick();
expect(store.state.sreChatContext.data.rca_analysis).toBe("Existing RCA");
const contextData = wrapper.vm.incidentContextData;
expect(contextData.rca_analysis).toBe("Existing RCA");
});
it("should include stream content when no existing RCA", () => {
it("should include stream content when no existing RCA", async () => {
wrapper.vm.rcaStreamContent = "Streaming RCA content";
wrapper.vm.incidentDetails.topology_context = undefined;
wrapper.vm.openSREChat();
await nextTick();
expect(store.state.sreChatContext.data.rca_analysis).toBe("Streaming RCA content");
const contextData = wrapper.vm.incidentContextData;
expect(contextData.rca_analysis).toBe("Streaming RCA content");
});
});
@@ -881,20 +907,24 @@ describe("IncidentDetailDrawer.vue", () => {
describe("Close Functionality", () => {
it("should close drawer", async () => {
wrapper = await createWrapper();
const pushSpy = vi.spyOn(router, 'push');
wrapper.vm.close();
await nextTick();
expect(wrapper.emitted("close")).toBeTruthy();
// Should navigate back to incident list
expect(pushSpy).toHaveBeenCalledWith({ name: "incidentList" });
});
it("should emit update:modelValue on close", async () => {
it("should navigate back to incident list on close", async () => {
wrapper = await createWrapper();
const pushSpy = vi.spyOn(router, 'push');
wrapper.vm.close();
await nextTick();
expect(wrapper.emitted("close")).toBeTruthy();
// Verify navigation to incident list
expect(pushSpy).toHaveBeenCalledWith({ name: "incidentList" });
});
});
+261 -160
View File
@@ -15,98 +15,112 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div
:class="[store.state.theme === 'dark' ? 'bg-dark' : 'bg-white']"
class="tw:h-full tw:flex tw:flex-col"
style="width: 94vw"
>
<q-page data-test="incident-detail-page" class="q-pa-none" style="height: calc(100vh - 50px); overflow: hidden;">
<div class="tw:w-full tw:h-full tw:px-[0.625rem] q-mt-xs tw:pb-[0.625rem]">
<!-- Header -->
<div class="incident-detail-header row items-center no-wrap q-px-md tw:p-4">
<div class="col">
<div class="tw:flex tw:items-center tw:gap-3 tw:flex-wrap">
<div class="tw:text-[18px]">
{{ t("alerts.incidents.header") }}
</div>
<!-- Incident name with colored indicator -->
<span
:class="[
'tw:font-bold tw:px-2 tw:py-1 tw:rounded-md tw:max-w-xs tw:truncate tw:inline-block',
store.state.theme === 'dark'
? 'tw:text-blue-400 tw:bg-blue-900/50'
: 'tw:text-blue-600 tw:bg-blue-50'
]"
data-test="incident-detail-title"
<div class="row items-center no-wrap card-container tw:py-[0.675rem] tw:h-[68px] tw:px-[0.675rem] tw:mb-[0.675rem]">
<div class="flex items-center tw:gap-3 tw:flex-1">
<div
data-test="incident-detail-back-btn"
class="flex justify-center items-center q-mr-md cursor-pointer"
style="
border: 1.5px solid;
border-radius: 50%;
width: 22px;
height: 22px;
"
title="Go Back"
@click="close"
>
<q-icon name="arrow_back_ios_new" size="14px" />
</div>
<div class="text-h6">
{{ t("alerts.incidents.header") }}
</div>
<!-- Incident name with colored indicator -->
<span
v-if="incidentDetails"
:class="[
'tw:font-bold tw:px-2 tw:py-1 tw:rounded-md tw:max-w-xs tw:truncate tw:inline-block',
store.state.theme === 'dark'
? 'tw:text-blue-400 tw:bg-blue-900/50'
: 'tw:text-blue-600 tw:bg-blue-50'
]"
data-test="incident-detail-title"
>
{{ incidentDetails.title }}
<q-tooltip v-if="incidentDetails && incidentDetails.title.length > 35" class="tw:text-xs">
{{ incidentDetails.title }}
</q-tooltip>
</span>
<!-- Compact Status, Severity, Alerts badges -->
<div v-if="incidentDetails" class="tw:flex tw:items-center tw:gap-2">
<!-- Status Badge -->
<q-badge
:color="getStatusColor(incidentDetails.status)"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
outline
>
{{ incidentDetails?.title }}
<q-tooltip v-if="incidentDetails && incidentDetails.title.length > 35" class="tw:text-xs">
{{ incidentDetails.title }}
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="info" size="14px" />
<span>{{ getStatusLabel(incidentDetails.status) }}</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.status") }}: {{ getStatusLabel(incidentDetails.status) }}
</q-tooltip>
</span>
</q-badge>
<!-- Compact Status, Severity, Alerts badges -->
<div v-if="incidentDetails" class="tw:flex tw:items-center tw:gap-2">
<!-- Status Badge -->
<q-badge
:color="getStatusColor(incidentDetails.status)"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
outline
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="info" size="14px" />
<span>{{ getStatusLabel(incidentDetails.status) }}</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.status") }}: {{ getStatusLabel(incidentDetails.status) }}
</q-tooltip>
</q-badge>
<!-- Severity Badge -->
<q-badge
:style="{ backgroundColor: getSeverityColorHex(incidentDetails.severity), color: '#fff' }"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="warning" size="14px" />
<span>{{ incidentDetails.severity }}</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.severity") }}: {{ incidentDetails.severity }}
</q-tooltip>
</q-badge>
<!-- Severity Badge -->
<q-badge
:style="{ backgroundColor: getSeverityColorHex(incidentDetails.severity), color: '#fff' }"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="warning" size="14px" />
<span>{{ incidentDetails.severity }}</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.severity") }}: {{ incidentDetails.severity }}
</q-tooltip>
</q-badge>
<!-- Alert Count Badge -->
<q-badge
color="primary"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
outline
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="notifications_active" size="14px" />
<span>{{ incidentDetails.alert_count }} Alerts</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.alertCount") }}: {{ incidentDetails.alert_count }} correlated alerts
</q-tooltip>
</q-badge>
</div>
<!-- Alert Count Badge -->
<q-badge
color="primary"
class="tw:px-2.5 tw:py-1.5 tw:cursor-default"
outline
>
<div class="tw:flex tw:items-center tw:gap-1.5">
<q-icon name="notifications_active" size="14px" />
<span>{{ incidentDetails.alert_count }} Alerts</span>
</div>
<q-tooltip :delay="200" class="tw:text-xs">
{{ t("alerts.incidents.alertCount") }}: {{ incidentDetails.alert_count }} correlated alerts
</q-tooltip>
</q-badge>
</div>
</div>
<div class="col-auto">
<q-btn
data-test="incident-detail-close-btn"
@click="close"
round
flat
icon="cancel"
/>
</div>
</div>
<q-separator />
<!-- Content -->
<div v-if="!loading && incidentDetails" class="tw:flex-1 tw:flex tw:overflow-hidden">
<div v-if="!loading && incidentDetails" class="card-container tw:flex tw:overflow-hidden" style="height: calc(100vh - 130px);">
<!-- AI Chat Panel (conditionally shown on the right) -->
<div
v-if="showAIChat"
class="ai-chat-panel tw:w-[380px] tw:flex-shrink-0 tw:flex tw:flex-col tw:overflow-hidden"
:class="store.state.theme === 'dark' ? 'tw:border-l tw:border-gray-700' : 'tw:border-l tw:border-gray-200'"
style="order: 3;"
>
<SREChat
context-type="incident"
:context-data="incidentContextData"
@close="closeAIChat"
/>
</div>
<!-- Left Column: Incident Details -->
<div class="incident-details-column tw:w-[400px] tw:flex-shrink-0 tw:flex tw:flex-col" :class="store.state.theme === 'dark' ? 'tw:border-r tw:border-gray-700' : 'tw:border-r tw:border-gray-200'">
<div class="incident-details-column tw:w-[400px] tw:flex-shrink-0 tw:flex tw:flex-col" :class="store.state.theme === 'dark' ? 'tw:border-r tw:border-gray-700' : 'tw:border-r tw:border-gray-200'" style="order: 1;">
<!-- Top Section (52% height) - Table of Contents -->
<div
@@ -440,49 +454,103 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Right Column: Tabs and Content -->
<div class="tabs-content-column tw:flex-1 tw:flex tw:flex-col tw:overflow-hidden">
<div class="tabs-content-column tw:flex-1 tw:flex tw:flex-col tw:overflow-hidden" style="order: 2;">
<!-- Tabs -->
<div class="tw:flex tw:items-center tw:justify-between tw:px-4 tw:pt-4 tw:pb-2 q-px-md tw:flex-shrink-0">
<q-tabs v-model="activeTab" inline-label dense no-caps align="left">
<q-tab
name="incidentAnalysis"
icon="psychology"
label="Incident Analysis"
/>
<q-tab
name="serviceGraph"
icon="hub"
label="Alert Graph"
/>
<q-tab
name="alertTriggers"
icon="notifications_active"
>
<template #default>
<div class="tw:flex tw:items-center tw:gap-1.5">
<span>Alert Triggers</span>
<span class="tw:text-xs tw:opacity-70">({{ triggers.length }})</span>
</div>
</template>
</q-tab>
<div class="tw:flex tw:items-center tw:gap-2">
<q-tabs v-model="activeTab" inline-label dense no-caps align="left">
<q-tab
name="incidentAnalysis"
icon="psychology"
label="Incident Analysis"
/>
<q-tab
name="serviceGraph"
icon="hub"
label="Alert Graph"
/>
<q-tab
name="alertTriggers"
icon="notifications_active"
>
<template #default>
<div class="tw:flex tw:items-center tw:gap-1.5">
<span>Alert Triggers</span>
<span class="tw:text-xs tw:opacity-70">({{ triggers.length }})</span>
</div>
</template>
</q-tab>
<!-- New Telemetry Tabs -->
<q-tab
name="logs"
icon="description"
:label="t('common.logs')"
/>
<q-tab
name="metrics"
icon="bar_chart"
:label="t('search.metrics')"
/>
<q-tab
name="traces"
icon="timeline"
:label="t('menu.traces')"
/>
</q-tabs>
<!-- Show telemetry tabs inline when AI chat is closed -->
<q-tab
v-if="!showAIChat"
name="logs"
icon="description"
:label="t('common.logs')"
/>
<q-tab
v-if="!showAIChat"
name="metrics"
icon="bar_chart"
:label="t('search.metrics')"
/>
<q-tab
v-if="!showAIChat"
name="traces"
icon="timeline"
:label="t('menu.traces')"
/>
</q-tabs>
<!-- Telemetry Dropdown Menu - Only show when AI chat is open -->
<q-btn
v-if="showAIChat"
flat
dense
icon="menu"
size="sm"
class="tw:ml-2"
>
<q-tooltip :delay="500">View Telemetry Data</q-tooltip>
<q-menu>
<q-list dense style="min-width: 150px;">
<q-item
clickable
v-close-popup
@click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'bg-primary text-white' : ''"
>
<q-item-section avatar>
<q-icon name="description" size="18px" />
</q-item-section>
<q-item-section>{{ t('common.logs') }}</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="activeTab = 'metrics'"
:class="activeTab === 'metrics' ? 'bg-primary text-white' : ''"
>
<q-item-section avatar>
<q-icon name="bar_chart" size="18px" />
</q-item-section>
<q-item-section>{{ t('search.metrics') }}</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="activeTab = 'traces'"
:class="activeTab === 'traces' ? 'bg-primary text-white' : ''"
>
<q-item-section avatar>
<q-icon name="timeline" size="18px" />
</q-item-section>
<q-item-section>{{ t('menu.traces') }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<!-- Action buttons -->
<div class="tw:flex tw:gap-2 tw:ml-auto tw:items-center">
@@ -495,10 +563,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
dense
@click="acknowledgeIncident"
:loading="updating"
class="tw:px-3"
class="tw:px-2"
>
<q-icon name="check_circle" size="16px" class="tw:mr-1" />
{{ t("alerts.incidents.acknowledge") }}
<q-icon name="check_circle" size="16px" :class="showAIChat ? '' : 'tw:mr-1'" />
<span v-if="!showAIChat">{{ t("alerts.incidents.acknowledge") }}</span>
<q-tooltip :delay="500">Mark incident as acknowledged and being worked on</q-tooltip>
</q-btn>
<q-btn
@@ -510,10 +578,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
dense
@click="resolveIncident"
:loading="updating"
class="tw:px-3"
class="tw:px-2"
>
<q-icon name="task_alt" size="16px" class="tw:mr-1" />
{{ t("alerts.incidents.resolve") }}
<q-icon name="task_alt" size="16px" :class="showAIChat ? '' : 'tw:mr-1'" />
<span v-if="!showAIChat">{{ t("alerts.incidents.resolve") }}</span>
<q-tooltip :delay="500">Mark incident as resolved and close it</q-tooltip>
</q-btn>
<q-btn
@@ -525,10 +593,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
dense
@click="reopenIncident"
:loading="updating"
class="tw:px-3"
class="tw:px-2"
>
<q-icon name="refresh" size="16px" class="tw:mr-1" />
{{ t("alerts.incidents.reopen") }}
<q-icon name="refresh" size="16px" :class="showAIChat ? '' : 'tw:mr-1'" />
<span v-if="!showAIChat">{{ t("alerts.incidents.reopen") }}</span>
<q-tooltip :delay="500">Reopen this resolved incident</q-tooltip>
</q-btn>
@@ -851,6 +919,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<q-spinner-hourglass size="lg" color="primary" />
</div>
</div>
</q-page>
</template>
<script lang="ts">
@@ -871,15 +940,16 @@ import { marked } from 'marked';
import DOMPurify from 'dompurify';
import TelemetryCorrelationDashboard from "@/plugins/correlation/TelemetryCorrelationDashboard.vue";
import IncidentServiceGraph from "./IncidentServiceGraph.vue";
import SREChat from "@/components/SREChat.vue";
export default defineComponent({
name: "IncidentDetailDrawer",
components: {
TelemetryCorrelationDashboard,
IncidentServiceGraph,
SREChat,
},
emits: ["close", "status-updated"],
setup(_props, { emit }) {
setup() {
const { t } = useI18n();
const store = useStore();
const $q = useQuasar();
@@ -892,6 +962,7 @@ export default defineComponent({
const alerts = ref<any[]>([]);
const rcaLoading = ref(false);
const rcaStreamContent = ref("");
const showAIChat = ref(false);
// Tab management
const activeTab = ref("incidentAnalysis");
@@ -967,6 +1038,27 @@ export default defineComponent({
}
});
// Computed property for SRE chat incident context
const incidentContextData = computed(() => {
if (!incidentDetails.value) return null;
return {
id: incidentDetails.value.id,
title: incidentDetails.value.title,
status: incidentDetails.value.status,
severity: incidentDetails.value.severity,
alert_count: incidentDetails.value.alert_count,
first_alert_at: incidentDetails.value.first_alert_at,
last_alert_at: incidentDetails.value.last_alert_at,
stable_dimensions: incidentDetails.value.stable_dimensions,
topology_context: incidentDetails.value.topology_context,
triggers: triggers.value,
rca_analysis: hasExistingRca.value
? incidentDetails.value?.topology_context?.suggested_root_cause
: rcaStreamContent.value,
};
});
// Computed properties for TelemetryCorrelationDashboard
const telemetryTimeRange = computed(() => {
if (!incidentDetails.value) {
@@ -1028,12 +1120,12 @@ export default defineComponent({
}
};
// Watch for URL query parameter
// Watch for URL route parameter
watch(
() => router.currentRoute.value.query.incident_id,
() => router.currentRoute.value.params.id,
(incidentIdFromUrl) => {
if (incidentIdFromUrl && typeof incidentIdFromUrl === 'string') {
// Load incident from URL query parameter
// Load incident from URL route parameter
// Validate incident ID format (e.g., UUID validation)
if (incidentIdFromUrl.trim().length > 0) {
loadDetails(incidentIdFromUrl);
@@ -1055,7 +1147,9 @@ export default defineComponent({
// Clear correlation data when closing
correlationData.value = null;
correlationError.value = null;
emit("close");
// Navigate back to incident list
router.push({ name: "incidentList" });
};
const updateStatus = async (newStatus: "open" | "acknowledged" | "resolved") => {
@@ -1593,31 +1687,12 @@ export default defineComponent({
};
const openSREChat = () => {
// Set SRE chat context with full incident context
store.state.sreChatContext = {
type: 'incident',
data: {
id: incidentDetails.value?.id,
title: incidentDetails.value?.title,
status: incidentDetails.value?.status,
severity: incidentDetails.value?.severity,
alert_count: incidentDetails.value?.alert_count,
first_alert_at: incidentDetails.value?.first_alert_at,
last_alert_at: incidentDetails.value?.last_alert_at,
stable_dimensions: incidentDetails.value?.stable_dimensions,
topology_context: incidentDetails.value?.topology_context,
triggers: triggers.value,
rca_analysis: hasExistingRca.value
? incidentDetails.value?.topology_context?.suggested_root_cause
: rcaStreamContent.value,
},
};
// Toggle the chat panel within the incident detail page
showAIChat.value = !showAIChat.value;
};
// Close the drawer first
close();
// Then open the SRE chat
store.dispatch("setIsSREChatOpen", true);
const closeAIChat = () => {
showAIChat.value = false;
};
const getTimezone = () => {
@@ -1653,6 +1728,7 @@ export default defineComponent({
hasCorrelatedData,
hasAnyStreams,
telemetryTimeRange,
incidentContextData,
refreshCorrelation,
close,
acknowledgeIncident,
@@ -1660,6 +1736,8 @@ export default defineComponent({
reopenIncident,
triggerRca,
openSREChat,
closeAIChat,
showAIChat,
scrollToSection,
toggleSection,
getStatusColor,
@@ -1786,6 +1864,29 @@ body.body--dark .incident-details-column::-webkit-scrollbar-thumb,
body.body--dark .tabs-content-column .tw:overflow-auto::-webkit-scrollbar-thumb {
background: #475569;
}
/* AI Chat Panel Styles */
.ai-chat-panel {
min-width: 380px;
max-width: 380px;
}
.ai-chat-panel::-webkit-scrollbar {
width: 6px;
}
.ai-chat-panel::-webkit-scrollbar-track {
background: transparent;
}
.ai-chat-panel::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
body.body--dark .ai-chat-panel::-webkit-scrollbar-thumb {
background: #475569;
}
</style>
<style lang="scss">
+17 -22
View File
@@ -449,26 +449,35 @@ describe("IncidentList.vue", () => {
it("should set selected incident and navigate when viewing incident", async () => {
const incident = wrapper.vm.incidents[0];
const pushSpy = vi.spyOn(router, 'push');
// viewIncident sets selectedIncident and navigates via router
// viewIncident navigates via router to incident detail page
wrapper.vm.viewIncident(incident);
// selectedIncident is set synchronously
expect(wrapper.vm.selectedIncident).toBe(incident);
// showDetailDrawer is set after router navigation completes
// In tests, we just verify the navigation intent by checking selectedIncident
// Verify router.push was called with correct route
expect(pushSpy).toHaveBeenCalledWith({
name: "incidentDetail",
params: {
id: incident.id,
},
});
});
it("should set correct incident when viewing", async () => {
const incident = createIncident({ id: "test-123", title: "Test Incident" });
wrapper.vm.incidents.push(incident);
const pushSpy = vi.spyOn(router, 'push');
await wrapper.vm.viewIncident(incident);
await flushPromises();
expect(wrapper.vm.selectedIncident.id).toBe("test-123");
expect(wrapper.vm.selectedIncident.title).toBe("Test Incident");
// Verify router.push was called with correct incident ID
expect(pushSpy).toHaveBeenCalledWith({
name: "incidentDetail",
params: {
id: "test-123",
},
});
});
});
@@ -561,20 +570,6 @@ describe("IncidentList.vue", () => {
});
});
describe("Status Updated Event Handler", () => {
beforeEach(async () => {
wrapper = createWrapper();
await flushPromises();
vi.clearAllMocks();
});
it("should reload incidents when status is updated from drawer", async () => {
await wrapper.vm.onStatusUpdated();
expect(incidentsService.list).toHaveBeenCalled();
});
});
describe("Utility Functions - Status Colors", () => {
beforeEach(() => {
wrapper = createWrapper();
+46 -78
View File
@@ -15,10 +15,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div data-test="incident-list" class="tw:w-full tw:h-full tw:pl-[0.625rem] tw:pb-[0.625rem]">
<!-- Incidents table -->
<div class="tw:w-full tw:h-full tw:pb-[0.625rem]">
<div class="card-container tw:h-[calc(100vh-120px)]">
<div data-test="incident-list" class="flex q-mt-xs">
<div class="tw:w-full tw:h-full tw:px-[0.625rem] tw:pb-[0.625rem]">
<!-- Header with title and search -->
<div class="card-container tw:mb-[0.625rem]">
<div class="tw:flex tw:justify-between tw:items-center tw:w-full tw:py-3 tw:px-4 tw:h-[68px]">
<div class="q-table__title tw:font-[600]" data-test="incidents-list-title">
{{ t("alerts.incidents.title") }}
</div>
<div class="tw:flex tw:items-center">
<q-input
v-model="searchQuery"
dense
borderless
:placeholder="t('alerts.incidents.search')"
data-test="incident-search-input"
clearable
class="o2-search-input"
>
<template #prepend>
<q-icon class="o2-search-input-icon" name="search" />
</template>
</q-input>
</div>
</div>
</div>
<!-- Incidents table -->
<div class="tw:w-full tw:h-full tw:pb-[0.625rem]">
<div class="card-container tw:h-[calc(100vh-128px)]">
<q-table
ref="qTableRef"
v-model:pagination="pagination"
@@ -168,19 +193,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</q-table>
</div>
</div>
<!-- Incident detail drawer -->
<q-dialog
v-model="showDetailDrawer"
position="right"
full-height
:maximized="true"
>
<IncidentDetailDrawer
@close="closeDrawer"
@status-updated="onStatusUpdated"
/>
</q-dialog>
</div>
</div>
</template>
@@ -192,7 +205,6 @@ import { useQuasar } from "quasar";
import { useRouter } from "vue-router";
import { date } from "quasar";
import incidentsService, { Incident } from "@/services/incidents";
import IncidentDetailDrawer from "./IncidentDetailDrawer.vue";
import QTablePagination from "@/components/shared/grid/Pagination.vue";
import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
import NoData from "../shared/grid/NoData.vue";
@@ -200,18 +212,11 @@ import NoData from "../shared/grid/NoData.vue";
export default defineComponent({
name: "IncidentList",
components: {
IncidentDetailDrawer,
QTablePagination,
O2AIContextAddBtn,
NoData,
},
props: {
searchQuery: {
type: String,
default: "",
},
},
setup(props) {
setup() {
const { t } = useI18n();
const store = useStore();
const $q = useQuasar();
@@ -221,8 +226,7 @@ export default defineComponent({
const loading = ref(false);
const incidents = ref<Incident[]>([]);
const allIncidents = ref<Incident[]>([]); // Store all incidents for FE filtering
const showDetailDrawer = ref(false);
const selectedIncident = ref<Incident | null>(null); // Keep for reference but not passed to drawer
const searchQuery = ref("");
// Filter state for status and severity columns
const statusFilter = ref<string[]>([]);
@@ -375,7 +379,7 @@ export default defineComponent({
const limit = 1000; // Large limit for FE filtering
const offset = 0;
// Keep keyword parameter for future BE implementation
const keyword = undefined; // props.searchQuery?.trim() || undefined;
const keyword = undefined; // searchQuery.value?.trim() || undefined;
const response = await incidentsService.list(
org,
@@ -389,7 +393,7 @@ export default defineComponent({
allIncidents.value = response.data.incidents;
// Apply frontend search filter
const filteredIncidents = applyFrontendSearch(allIncidents.value, props.searchQuery);
const filteredIncidents = applyFrontendSearch(allIncidents.value, searchQuery.value);
// Apply pagination on filtered results
const startIndex = (pagination.value.page - 1) * pagination.value.rowsPerPage;
@@ -414,7 +418,7 @@ export default defineComponent({
pagination.value.descending = props.pagination.descending;
// For FE filtering, just reapply filters and pagination without API call
const filteredIncidents = applyFrontendSearch(allIncidents.value, props.searchQuery);
const filteredIncidents = applyFrontendSearch(allIncidents.value, searchQuery.value);
const startIndex = (pagination.value.page - 1) * pagination.value.rowsPerPage;
const endIndex = startIndex + pagination.value.rowsPerPage;
incidents.value = filteredIncidents.slice(startIndex, endIndex);
@@ -422,17 +426,12 @@ export default defineComponent({
};
const viewIncident = (incident: Incident) => {
selectedIncident.value = incident;
// Add incident ID to URL and open drawer after navigation completes
// Navigate to incident detail page
router.push({
query: {
...router.currentRoute.value.query,
incident_id: incident.id,
name: "incidentDetail",
params: {
id: incident.id,
},
}).then(() => {
// Open drawer after URL is updated
showDetailDrawer.value = true;
});
};
@@ -466,20 +465,6 @@ export default defineComponent({
updateStatus(incident, "open");
};
const onStatusUpdated = () => {
loadIncidents();
};
const closeDrawer = () => {
// Close drawer immediately for better UX
showDetailDrawer.value = false;
selectedIncident.value = null;
// Remove incident_id from URL
const query = { ...router.currentRoute.value.query };
delete query.incident_id;
router.replace({ query }); // Use replace instead of push to not add history entry
};
const getStatusColor = (status: string) => {
switch (status) {
@@ -538,39 +523,21 @@ export default defineComponent({
onMounted(async () => {
await loadIncidents();
// Check if there's an incident_id in the URL and open the drawer
const incidentIdFromUrl = router.currentRoute.value.query.incident_id as string;
if (incidentIdFromUrl) {
// Just open the drawer - the IncidentDetailDrawer will read the ID from URL
showDetailDrawer.value = true;
}
});
// Watch for search query changes and apply FE filter
watch(() => props.searchQuery, () => {
watch(() => searchQuery.value, () => {
// Reset to page 1 when search query changes
pagination.value.page = 1;
// Apply FE search filter without API call
const filteredIncidents = applyFrontendSearch(allIncidents.value, props.searchQuery);
const filteredIncidents = applyFrontendSearch(allIncidents.value, searchQuery.value);
const startIndex = 0; // Always start from first page
const endIndex = pagination.value.rowsPerPage;
incidents.value = filteredIncidents.slice(startIndex, endIndex);
pagination.value.rowsNumber = filteredIncidents.length;
});
// Watch for drawer closing (handles ESC key, clicking outside, etc.)
watch(showDetailDrawer, (isOpen) => {
if (!isOpen && router.currentRoute.value.query.incident_id) {
// Drawer was closed but incident_id is still in URL - clean it up
const query = { ...router.currentRoute.value.query };
delete query.incident_id;
router.replace({ query });
selectedIncident.value = null;
}
});
// Filter toggle functions
const toggleStatusFilter = (status: string) => {
const index = statusFilter.value.indexOf(status);
@@ -632,16 +599,13 @@ export default defineComponent({
severityOptions,
pagination,
columns,
showDetailDrawer,
selectedIncident,
searchQuery,
loadIncidents,
onRequest,
viewIncident,
closeDrawer,
acknowledgeIncident,
resolveIncident,
reopenIncident,
onStatusUpdated,
getStatusColor,
getStatusLabel,
getSeverityColor,
@@ -667,4 +631,8 @@ export default defineComponent({
justify-content: space-between;
align-items: center;
}
.o2-search-input {
width: 250px;
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="tw:w-full tw:h-full tw:px-[0.625rem] tw:py-[0.625rem]" style="display: flex; flex-direction: column;">
<div class="tw:w-full tw:px-[0.625rem] tw:py-[0.625rem] card-container" style="display: flex; flex-direction: column; height: calc(100vh - 52px);">
<!-- 1st section - Streams container -->
<div class="feature-card"
+23
View File
@@ -39,6 +39,7 @@ const LogStream = () => import("@/views/LogStream.vue");
const Alerts = () => import("@/views/AppAlerts.vue");
const Dashboards = () => import("@/views/Dashboards/Dashboards.vue");
const AlertList = () => import("@/components/alerts/AlertList.vue");
const IncidentList = () => import("@/components/alerts/IncidentList.vue");
const Settings = () => import("@/components/settings/index.vue");
const Functions = () => import("@/views/Functions.vue");
@@ -413,6 +414,28 @@ const useRoutes = () => {
routeGuard(to, from, next);
},
},
{
path: "incidents",
name: "incidentList",
component: IncidentList,
meta: {
title: "Incidents",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
},
{
path: "incidents/:id",
name: "incidentDetail",
component: () => import("@/components/alerts/IncidentDetailDrawer.vue"),
meta: {
title: "Incident Detail",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
},
{
path: "short/:id",
name: "shortUrl",
+11 -4
View File
@@ -213,6 +213,7 @@ import {
outlinedDescription,
outlinedCode,
outlinedDevices,
outlinedNotificationsActive,
} from "@quasar/extras/material-icons-outlined";
import SlackIcon from "@/components/icons/SlackIcon.vue";
import ManagementIcon from "@/components/icons/ManagementIcon.vue";
@@ -435,6 +436,12 @@ export default defineComponent({
link: "/alerts",
name: "alertList",
},
{
title: t("menu.incidents"),
icon: outlinedNotificationsActive,
link: "/incidents",
name: "incidentList",
},
{
title: t("menu.ingestion"),
icon: outlinedFilterAlt,
@@ -557,16 +564,16 @@ export default defineComponent({
const updateActionsMenu = () => {
if (isActionsEnabled.value) {
const alertIndex = linksList.value.findIndex(
(link) => link.name === "alertList",
const incidentIndex = linksList.value.findIndex(
(link) => link.name === "incidentList",
);
const actionExists = linksList.value.some(
(link) => link.name === "actionScripts",
);
if (alertIndex !== -1 && !actionExists) {
linksList.value.splice(alertIndex + 1, 0, {
if (incidentIndex !== -1 && !actionExists) {
linksList.value.splice(incidentIndex + 1, 0, {
title: t("menu.actions"),
icon: outlinedCode,
link: "/actions",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Berichte",
"pipeline": "Rohrleitungen",
"language": "Sprache",
"actions": "Aktionen"
"actions": "Aktionen",
"incidents": "Vorfälle"
},
"rum": {
"title": "Echte Benutzerüberwachung",
+2
View File
@@ -324,6 +324,7 @@
"help": "Help",
"dashboard": "Dashboards",
"alerts": "Alerts",
"incidents": "Incidents",
"openapi": "OpenAPI",
"docs": "Docs",
"billings": "Billing",
@@ -1738,6 +1739,7 @@
"incidents": {
"title": "Incidents",
"header": "Alert Incidents",
"search": "Search incidents...",
"noIncidents": "No incidents found",
"id": "ID",
"status": "Status",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Informes",
"pipeline": "Tuberías",
"language": "Idioma",
"actions": "Acciones"
"actions": "Acciones",
"incidents": "Incidentes"
},
"rum": {
"title": "Monitorización de usuarios reales",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Rapports",
"pipeline": "Canalisations",
"language": "Langue",
"actions": "Actions"
"actions": "Actions",
"incidents": "Incidents"
},
"rum": {
"title": "Surveillance réelle des utilisateurs",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Rapporti",
"pipeline": "Oleodotti",
"language": "Lingua",
"actions": "Azioni"
"actions": "Azioni",
"incidents": "Incidenti"
},
"rum": {
"title": "Monitoraggio degli utenti reali",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "レポート",
"pipeline": "パイプライン",
"language": "言語",
"actions": "アクション"
"actions": "アクション",
"incidents": "インシデント"
},
"rum": {
"title": "リアルユーザーモニタリング",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "보고서",
"pipeline": "파이프라인",
"language": "언어",
"actions": "액션"
"actions": "액션",
"incidents": "인시던트"
},
"rum": {
"title": "실제 사용자 모니터링",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Rapporten",
"pipeline": "Pijpleidingen",
"language": "taal",
"actions": "Acties"
"actions": "Acties",
"incidents": "Incidenten"
},
"rum": {
"title": "Monitoring van echte gebruikers",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Relatórios",
"pipeline": "Oleodutos",
"language": "Idioma",
"actions": "Ações"
"actions": "Ações",
"incidents": "Incidentes"
},
"rum": {
"title": "Monitoramento de usuários reais",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "Raporlar",
"pipeline": "Boru hatları",
"language": "Dil",
"actions": "Eylemler"
"actions": "Eylemler",
"incidents": "Olaylar"
},
"rum": {
"title": "Gerçek Kullanıcı İzleme",
+2 -1
View File
@@ -334,7 +334,8 @@
"report": "报表",
"pipeline": "流水线",
"language": "语言",
"actions": "操作"
"actions": "操作",
"incidents": "事件"
},
"rum": {
"title": "用户体验监控",
@@ -529,12 +529,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Tab Panels (no tabs in embedded mode, controlled by parent) -->
<q-tab-panels
<q-card
v-model="activeTab"
animated
class="correlation-content tw:flex-1 tw:overflow-auto"
>
<q-tab-panel name="logs" class="tw:p-0">
<div v-if="activeTab == 'logs'" class="tw:p-0">
<!-- Refresh Button -->
<div v-if="logsDashboardData" class="tw:p-2 tw:border-b tw:border-solid tw:border-[var(--o2-border-color)] tw:flex tw:justify-end">
<q-btn
@@ -549,6 +549,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<RenderDashboardCharts
v-if="logsDashboardData"
:key="logsDashboardRenderKey"
@@ -558,46 +559,48 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:allowAlertCreation="false"
searchType="dashboards"
/>
</q-tab-panel>
</div>
<div v-if="activeTab == 'metrics'" class="tw:h-full">
<div class=" tw:p-2 tw:border-b tw:border-solid tw:border-[var(--o2-border-color)] tw:flex tw:items-center tw:justify-end tw:gap-2">
<q-btn
v-if="dashboardData"
flat
dense
color="primary"
icon="refresh"
:label="t('common.refresh')"
@click="loadDashboard"
:loading="loading"
size="sm"
/>
<q-btn
outline
dense
no-caps
color="primary"
icon="show_chart"
:label="`${selectedMetricStreams.length} of ${uniqueMetricStreams.length} Metric(s)`"
@click="showMetricSelector = true"
>
<q-tooltip>{{ t('correlation.metricsTooltip') }}</q-tooltip>
</q-btn>
</div>
<div class="tw:p-0 tw:flex-1 tw:overflow-auto" style="height: calc(100vh - 272px);">
<RenderDashboardCharts
v-if="dashboardData"
:key="dashboardRenderKey"
:dashboardData="dashboardData"
:currentTimeObj="currentTimeObj"
:viewOnly="true"
:allowAlertCreation="false"
searchType="dashboards"
/>
</div>
</div>
<q-tab-panel name="metrics" class="tw:p-0">
<div class="tw:p-3 tw:border-b tw:border-solid tw:border-[var(--o2-border-color)] tw:flex tw:items-center tw:justify-end tw:gap-2">
<q-btn
v-if="dashboardData"
flat
dense
color="primary"
icon="refresh"
:label="t('common.refresh')"
@click="loadDashboard"
:loading="loading"
size="sm"
/>
<q-btn
outline
dense
no-caps
color="primary"
icon="show_chart"
:label="`${selectedMetricStreams.length} of ${uniqueMetricStreams.length} Metric(s)`"
@click="showMetricSelector = true"
>
<q-tooltip>{{ t('correlation.metricsTooltip') }}</q-tooltip>
</q-btn>
</div>
<RenderDashboardCharts
v-if="dashboardData"
:key="dashboardRenderKey"
:dashboardData="dashboardData"
:currentTimeObj="currentTimeObj"
:viewOnly="true"
:allowAlertCreation="false"
searchType="dashboards"
/>
</q-tab-panel>
<q-tab-panel name="traces" class="tw:p-0">
<div v-if="activeTab == 'traces'" class="tw:h-full">
<!-- Refresh Button -->
<div v-if="traceCorrelationMode !== null" class="tw:p-2 tw:border-b tw:border-solid tw:border-[var(--o2-border-color)] tw:flex tw:justify-end">
<q-btn
@@ -615,7 +618,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Loading State -->
<div
v-if="tracesLoading"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:h-full tw:py-20"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:h-[calc(100vh-272px)] tw:py-20"
>
<q-spinner-hourglass color="primary" size="3.75rem" class="tw:mb-4" />
<div class="tw:text-base">{{ t('correlation.loadingTraces') }}</div>
@@ -728,7 +731,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Traces Table -->
<div class="tw:p-3 tw:overflow-auto" style="max-height: calc(100% - 4rem)">
<div class="tw:p-3 tw:overflow-auto" style="max-height: calc(100vh - 332px)">
<q-table
:rows="tracesForDimensions"
:columns="traceListColumns"
@@ -816,8 +819,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ t('correlation.correlatedTracesFor', { service: serviceName }) }}
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</div>
</q-card>
</div>
<!-- Metric Stream Selector Dialog -->
@@ -896,6 +899,7 @@ import { SELECT_ALL_VALUE } from "@/utils/dashboard/constants";
import streamService from "@/services/stream";
import searchService from "@/services/search";
import { b64EncodeUnicode } from "@/utils/zincutils";
import LogstashDatasource from "@/components/ingestion/logs/LogstashDatasource.vue";
const RenderDashboardCharts = defineAsyncComponent(
() => import("@/views/Dashboards/RenderDashboardCharts.vue")