From da80cef2af4b5077462e1d79eafb8751bf72567f Mon Sep 17 00:00:00 2001 From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:07:58 +0530 Subject: [PATCH] fix: Log patterns refactoring (#9026) --- web/src/plugins/logs/SearchResult.vue | 699 +----------------- web/src/plugins/logs/TenstackTable.vue | 5 +- .../plugins/logs/patterns/PatternCard.spec.ts | 205 +++++ web/src/plugins/logs/patterns/PatternCard.vue | 178 +++++ .../patterns/PatternDetailsDialog.spec.ts | 331 +++++++++ .../logs/patterns/PatternDetailsDialog.vue | 294 ++++++++ .../plugins/logs/patterns/PatternList.spec.ts | 247 +++++++ web/src/plugins/logs/patterns/PatternList.vue | 95 +++ .../logs/patterns/PatternStatistics.spec.ts | 201 +++++ .../logs/patterns/PatternStatistics.vue | 206 ++++++ web/src/styles/_variables.scss | 10 +- web/src/styles/app.scss | 4 + web/src/styles/logs/search-result.scss | 10 + 13 files changed, 1810 insertions(+), 675 deletions(-) create mode 100644 web/src/plugins/logs/patterns/PatternCard.spec.ts create mode 100644 web/src/plugins/logs/patterns/PatternCard.vue create mode 100644 web/src/plugins/logs/patterns/PatternDetailsDialog.spec.ts create mode 100644 web/src/plugins/logs/patterns/PatternDetailsDialog.vue create mode 100644 web/src/plugins/logs/patterns/PatternList.spec.ts create mode 100644 web/src/plugins/logs/patterns/PatternList.vue create mode 100644 web/src/plugins/logs/patterns/PatternStatistics.spec.ts create mode 100644 web/src/plugins/logs/patterns/PatternStatistics.vue diff --git a/web/src/plugins/logs/SearchResult.vue b/web/src/plugins/logs/SearchResult.vue index d38f542814..555e7950c3 100644 --- a/web/src/plugins/logs/SearchResult.vue +++ b/web/src/plugins/logs/SearchResult.vue @@ -287,7 +287,7 @@ color="warning" size="xs"> Error while
Error while ]" > -
-
-
- - -
- Logs Analyzed -
-
- -
-
- {{ patternsState.scanSize.toLocaleString() }} -
- - Edit scan size - -
+ :statistics="patternsState?.patterns?.statistics" + :scanSize="patternsState.scanSize" + @update:scanSize="patternsState.scanSize = $event" + /> - -
- -
-
-
-
-
-
- - -
- Patterns Found -
-
- {{ - patternsState?.patterns?.statistics - ?.total_patterns_found || 0 - }} -
-
-
-
-
- - -
- Coverage -
-
- {{ - ( - patternsState?.patterns?.statistics - ?.coverage_percentage || 0 - ).toFixed(1) - }}% -
-
-
-
-
- - -
- Processing Time -
-
- {{ - patternsState?.patterns?.statistics?.extraction_time_ms || - 0 - }}ms -
-
-
-
-
-
- - -
- - - - -
-
- - {{ index + 1 }} - -
-
- -
-
- {{ pattern.template }} -
- - -
Template vs Description
-
Template: {{ pattern.template }}
-
Description: {{ pattern.description }}
-
-
-
- -
- {{ - pattern.frequency.toLocaleString() - }} - occurrences - • - {{ pattern.percentage.toFixed(2) }}% - ⚠️ ANOMALY -
-
-
- - - - - - - - - - - - Show details ({{ - pattern.examples?.[0]?.variables - ? Object.keys(pattern.examples[0].variables).length - : 0 - }} - variables, - {{ pattern.examples?.length || 0 }} examples) - -
-
-
-
-
-
- -
- -
- Extracting patterns from logs... -
-
- -
-
- 📊 -
-
- No patterns found -
-
-
- Only - {{ patternsState?.patterns?.statistics?.total_logs_analyzed }} - logs were analyzed. -
-
- Try increasing the time range or selecting a different stream with - more log data. -
Pattern extraction works best with at least 1000+ logs. -
-
-
+ @open-details="openPatternDetails" + @add-to-search="addPatternToSearch" + />
Error while - - - - -
-
-
Pattern Details
-
- Pattern {{ (selectedPattern as any).index + 1 }} of - {{ patternsState?.patterns?.patterns?.length || 0 }} -
-
-
- - - -
-
-
- - - - - - -
-
- Statistics -
-
-
- - -
- Occurrences -
-
- {{ - ( - selectedPattern as any - ).pattern.frequency.toLocaleString() - }} -
-
-
-
-
- - -
- Percentage -
-
- {{ - (selectedPattern as any).pattern.percentage.toFixed( - 2, - ) - }}% -
-
-
-
-
-
- - - This pattern is detected as an anomaly - -
-
- - -
-
- Pattern Template -
-
- {{ (selectedPattern as any).pattern.template }} -
-
- - -
-
- Variables ({{ - (selectedPattern as any).pattern.variables.length - }}) -
-
- - {{ - variable.name || "var_" + variable.index - }} - • - {{ variable.var_type || "unknown" }} - -
-
- - -
-
- Example Logs ({{ - (selectedPattern as any).pattern.examples.length - }}) -
-
- {{ example.log_message }} -
-
-
-
-
+ :selectedPattern="selectedPattern" + :totalPatterns="patternsState?.patterns?.patterns?.length || 0" + @navigate="navigatePatternDetail" + /> @@ -1021,9 +388,7 @@ import { import { copyToClipboard, useQuasar } from "quasar"; import { useStore } from "vuex"; import { useI18n } from "vue-i18n"; -import { outlinedInfo } from "@quasar/extras/material-icons-outlined"; -import HighLight from "../../components/HighLight.vue"; import { byString } from "../../utils/json"; import { getImageURL, useLocalWrapContent } from "../../utils/zincutils"; import useLogs from "../../composables/useLogs"; @@ -1032,7 +397,6 @@ import usePatterns from "@/composables/useLogs/usePatterns"; import { convertLogData } from "@/utils/logs/convertLogData"; import SanitizedHtmlRenderer from "@/components/SanitizedHtmlRenderer.vue"; import { useRouter } from "vue-router"; -import TenstackTable from "./TenstackTable.vue"; import { useSearchAround } from "@/composables/useLogs/searchAround"; import { usePagination } from "@/composables/useLogs/usePagination"; import { logsUtils } from "@/composables/useLogs/logsUtils"; @@ -1052,6 +416,15 @@ export default defineComponent({ TenstackTable: defineAsyncComponent(() => import("./TenstackTable.vue")), EqualIcon, NotEqualIcon, + PatternStatistics: defineAsyncComponent( + () => import("./patterns/PatternStatistics.vue"), + ), + PatternList: defineAsyncComponent( + () => import("./patterns/PatternList.vue"), + ), + PatternDetailsDialog: defineAsyncComponent( + () => import("./patterns/PatternDetailsDialog.vue"), + ), }, emits: [ "update:scroll", @@ -1243,7 +616,6 @@ export default defineComponent({ const totalHeight = ref(0); const selectedPattern = ref(null); const showPatternDetails = ref(false); - const isEditingScanSize = ref(false); const searchTableRef: any = ref(null); @@ -1437,12 +809,6 @@ export default defineComponent({ // Set the filter to be added to the query searchObj.data.stream.addToFilter = filterExpression; - - $q.notify({ - type: "positive", - message: `Pattern ${action === "include" ? "included in" : "excluded from"} search (${constants.length} match_all clause${constants.length > 1 ? "s" : ""})`, - timeout: 2000, - }); }; const getRowIndex = (next: boolean, prev: boolean, oldIndex: number) => { @@ -1627,10 +993,6 @@ export default defineComponent({ return [...new Set([...defaultFTSKeys, ...selectedStreamFTSKeys])]; }); - const toggleEditScanSize = () => { - isEditingScanSize.value = !isEditingScanSize.value; - }; - return { t, store, @@ -1686,9 +1048,6 @@ export default defineComponent({ navigatePatternDetail, addPatternToSearch, extractConstantsFromPattern, - isEditingScanSize, - toggleEditScanSize, - outlinedInfo, }; }, computed: { diff --git a/web/src/plugins/logs/TenstackTable.vue b/web/src/plugins/logs/TenstackTable.vue index 93837d9a57..90060cf013 100644 --- a/web/src/plugins/logs/TenstackTable.vue +++ b/web/src/plugins/logs/TenstackTable.vue @@ -237,11 +237,8 @@ name="warning" class="q-mr-xs" /> formattedRows?.[virtualRow.index]?.original?.isExpandedRow " :ref="(node: any) => node && rowVirtualizer.measureElement(node)" - class="tw-absolute tw-flex tw-w-max tw-items-center tw-justify-start tw-border-b tw-cursor-pointer" + class="tw-absolute tw-flex tw-w-max tw-items-center tw-justify-start tw-border-b tw-cursor-pointer hover:tw-bg-[var(--o2-hover-gray)]" :class="[ - store.state.theme === 'dark' - ? 'w-border-gray-800 hover:tw-bg-zinc-700' - : 'w-border-gray-100 hover:tw-bg-zinc-200', defaultColumns && !wrap && !(formattedRows[virtualRow.index]?.original as any)?.isExpandedRow diff --git a/web/src/plugins/logs/patterns/PatternCard.spec.ts b/web/src/plugins/logs/patterns/PatternCard.spec.ts new file mode 100644 index 0000000000..2f9af8eba6 --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternCard.spec.ts @@ -0,0 +1,205 @@ +// Copyright 2023 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 . + +import { describe, expect, it, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import * as quasar from "quasar"; +import PatternCard from "./PatternCard.vue"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [quasar.Notify], +}); + +describe("PatternCard", () => { + let wrapper: any; + const mockPattern = { + pattern_id: "pattern-1", + template: "User * logged in from IP *", + description: "User login pattern", + frequency: 1234, + percentage: 45.67, + is_anomaly: false, + variables: [ + { index: 0, name: "username", var_type: "string" }, + { index: 1, name: "ip_address", var_type: "ip" }, + ], + examples: [ + { + log_message: "User john logged in from IP 192.168.1.1", + variables: { username: "john", ip_address: "192.168.1.1" }, + }, + { + log_message: "User jane logged in from IP 10.0.0.1", + variables: { username: "jane", ip_address: "10.0.0.1" }, + }, + ], + }; + + const mockIndex = 0; + + beforeEach(() => { + wrapper = mount(PatternCard, { + props: { + pattern: mockPattern, + index: mockIndex, + }, + global: { + provide: { store }, + stubs: { + EqualIcon: { template: '
' }, + NotEqualIcon: { template: '
' }, + }, + }, + }); + }); + + it("should mount PatternCard component", () => { + expect(wrapper.exists()).toBe(true); + expect(wrapper.find('[data-test="pattern-card-0"]').exists()).toBe(true); + }); + + describe("Pattern Display", () => { + it("should display pattern rank (index + 1)", () => { + const rank = wrapper.find('[data-test="pattern-card-0-rank"]'); + expect(rank.exists()).toBe(true); + expect(rank.text()).toBe("1"); + }); + + it("should display pattern template", () => { + const template = wrapper.find('[data-test="pattern-card-0-template"]'); + expect(template.exists()).toBe(true); + expect(template.text()).toBe(mockPattern.template); + }); + + it("should display frequency with locale formatting", () => { + const frequency = wrapper.find('[data-test="pattern-card-0-frequency"]'); + expect(frequency.exists()).toBe(true); + expect(frequency.text()).toBe("1,234"); + }); + + it("should display percentage with 2 decimal places", () => { + const percentage = wrapper.find( + '[data-test="pattern-card-0-percentage"]', + ); + expect(percentage.exists()).toBe(true); + expect(percentage.text()).toBe("45.67%"); + }); + + it("should display info icon with tooltip", () => { + const infoIcon = wrapper.find( + '[data-test="pattern-card-0-info-icon"]', + ); + expect(infoIcon.exists()).toBe(true); + }); + }); + + describe("Anomaly Detection", () => { + it("should display anomaly badge when pattern is an anomaly", async () => { + await wrapper.setProps({ + pattern: { ...mockPattern, is_anomaly: true }, + index: mockIndex, + }); + + const anomalyBadge = wrapper.find( + '[data-test="pattern-card-0-anomaly-badge"]', + ); + expect(anomalyBadge.exists()).toBe(true); + expect(anomalyBadge.text()).toContain("ANOMALY"); + }); + + it("should not display anomaly badge when pattern is not an anomaly", () => { + const anomalyBadge = wrapper.find( + '[data-test="pattern-card-0-anomaly-badge"]', + ); + expect(anomalyBadge.exists()).toBe(false); + }); + }); + + describe("Pattern Actions", () => { + it("should display include button", () => { + const includeBtn = wrapper.find( + '[data-test="pattern-card-0-include-btn"]', + ); + expect(includeBtn.exists()).toBe(true); + }); + + it("should display exclude button", () => { + const excludeBtn = wrapper.find( + '[data-test="pattern-card-0-exclude-btn"]', + ); + expect(excludeBtn.exists()).toBe(true); + }); + + it("should display details icon", () => { + const detailsIcon = wrapper.find( + '[data-test="pattern-card-0-details-icon"]', + ); + expect(detailsIcon.exists()).toBe(true); + }); + + it("should emit click event when card is clicked", async () => { + const card = wrapper.find('[data-test="pattern-card-0"]'); + await card.trigger("click"); + + expect(wrapper.emitted("click")).toBeTruthy(); + expect(wrapper.emitted("click")![0]).toEqual([mockPattern, mockIndex]); + }); + + it("should emit include event when include button is clicked", async () => { + const includeBtn = wrapper.find( + '[data-test="pattern-card-0-include-btn"]', + ); + await includeBtn.trigger("click"); + + expect(wrapper.emitted("include")).toBeTruthy(); + expect(wrapper.emitted("include")![0]).toEqual([mockPattern]); + }); + + it("should emit exclude event when exclude button is clicked", async () => { + const excludeBtn = wrapper.find( + '[data-test="pattern-card-0-exclude-btn"]', + ); + await excludeBtn.trigger("click"); + + expect(wrapper.emitted("exclude")).toBeTruthy(); + expect(wrapper.emitted("exclude")![0]).toEqual([mockPattern]); + }); + + it("should not trigger card click when action buttons are clicked", async () => { + const includeBtn = wrapper.find( + '[data-test="pattern-card-0-include-btn"]', + ); + await includeBtn.trigger("click"); + + // Card click should not be emitted when button is clicked + expect(wrapper.emitted("click")).toBeFalsy(); + }); + }); + + describe("Multiple Patterns", () => { + it("should display correct rank for different indices", async () => { + await wrapper.setProps({ + pattern: mockPattern, + index: 5, + }); + + const rank = wrapper.find('[data-test="pattern-card-5-rank"]'); + expect(rank.exists()).toBe(true); + expect(rank.text()).toBe("6"); + }); + }); +}); diff --git a/web/src/plugins/logs/patterns/PatternCard.vue b/web/src/plugins/logs/patterns/PatternCard.vue new file mode 100644 index 0000000000..a18b4049cb --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternCard.vue @@ -0,0 +1,178 @@ + + + + + + + diff --git a/web/src/plugins/logs/patterns/PatternDetailsDialog.spec.ts b/web/src/plugins/logs/patterns/PatternDetailsDialog.spec.ts new file mode 100644 index 0000000000..7337ce6870 --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternDetailsDialog.spec.ts @@ -0,0 +1,331 @@ +// Copyright 2023 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 . + +import { describe, expect, it, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import * as quasar from "quasar"; +import PatternDetailsDialog from "./PatternDetailsDialog.vue"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [quasar.Dialog, quasar.Notify], +}); + +describe("PatternDetailsDialog", () => { + let wrapper: any; + const mockSelectedPattern = { + pattern: { + pattern_id: "pattern-1", + template: "User * logged in from IP *", + description: "User login pattern", + frequency: 1234, + percentage: 45.67, + is_anomaly: false, + variables: [ + { index: 0, name: "username", var_type: "string" }, + { index: 1, name: "ip_address", var_type: "ip" }, + ], + examples: [ + { + log_message: "User john logged in from IP 192.168.1.1", + variables: { username: "john", ip_address: "192.168.1.1" }, + }, + { + log_message: "User jane logged in from IP 10.0.0.1", + variables: { username: "jane", ip_address: "10.0.0.1" }, + }, + ], + }, + index: 0, + }; + + const totalPatterns = 10; + + beforeEach(() => { + wrapper = mount(PatternDetailsDialog, { + props: { + modelValue: true, + selectedPattern: mockSelectedPattern, + totalPatterns: totalPatterns, + }, + global: { + provide: { store }, + stubs: { + "q-dialog": { + template: '
', + props: ["modelValue"], + }, + }, + }, + }); + }); + + it("should mount PatternDetailsDialog component", () => { + expect(wrapper.exists()).toBe(true); + }); + + describe("Dialog Header", () => { + it("should display dialog title", () => { + const title = wrapper.find('[data-test="close-pattern-dialog"]'); + expect(title.exists()).toBe(true); + }); + + it("should display pattern count in subtitle", () => { + const text = wrapper.text(); + expect(text).toContain("Pattern 1 of 10"); + }); + + it("should have close button that can close dialog", async () => { + const closeBtn = wrapper.find('[data-test="close-pattern-dialog"]'); + expect(closeBtn.exists()).toBe(true); + // The close button has v-close-popup directive which will be handled by Quasar + // In real usage, this button will close the dialog + expect(closeBtn.element.tagName).toBe("BUTTON"); + }); + }); + + describe("Statistics Section", () => { + it("should display occurrences with locale formatting", () => { + const text = wrapper.text(); + expect(text).toContain("Occurrences"); + expect(text).toContain("1,234"); + }); + + it("should display percentage with 2 decimal places", () => { + const text = wrapper.text(); + expect(text).toContain("Percentage"); + expect(text).toContain("45.67%"); + }); + + it("should display anomaly banner when pattern is anomaly", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + pattern: { ...mockSelectedPattern.pattern, is_anomaly: true }, + }, + }); + + const text = wrapper.text(); + expect(text).toContain("This pattern is detected as an anomaly"); + }); + + it("should not display anomaly banner when pattern is not anomaly", () => { + const text = wrapper.text(); + expect(text).not.toContain("This pattern is detected as an anomaly"); + }); + }); + + describe("Description Section", () => { + it("should display pattern description", () => { + const text = wrapper.text(); + expect(text).toContain("Description"); + expect(text).toContain("User login pattern"); + }); + + it("should display fallback text when description is not available", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + pattern: { ...mockSelectedPattern.pattern, description: null }, + }, + }); + + const text = wrapper.text(); + expect(text).toContain("No description available"); + }); + }); + + describe("Template Section", () => { + it("should display pattern template", () => { + const text = wrapper.text(); + expect(text).toContain("Pattern Template"); + expect(text).toContain("User * logged in from IP *"); + }); + }); + + describe("Variables Section", () => { + it("should display variables table when variables exist", () => { + const table = wrapper.findComponent({ name: "QTable" }); + expect(table.exists()).toBe(true); + }); + + it("should display variables count in header", () => { + const text = wrapper.text(); + expect(text).toContain("Variables (2)"); + }); + + it("should display variable names in table", () => { + const text = wrapper.text(); + expect(text).toContain("username"); + expect(text).toContain("ip_address"); + }); + + it("should display variable types in table", () => { + const text = wrapper.text(); + expect(text).toContain("string"); + expect(text).toContain("ip"); + }); + + it("should not display variables section when no variables", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + pattern: { ...mockSelectedPattern.pattern, variables: [] }, + }, + }); + + const text = wrapper.text(); + expect(text).not.toContain("Variables"); + }); + }); + + describe("Examples Section", () => { + it("should display examples section header with count", () => { + const text = wrapper.text(); + expect(text).toContain("Example Logs (2)"); + }); + + it("should display all example log messages", () => { + const text = wrapper.text(); + expect(text).toContain("User john logged in from IP 192.168.1.1"); + expect(text).toContain("User jane logged in from IP 10.0.0.1"); + }); + + it("should not display examples section when no examples", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + pattern: { ...mockSelectedPattern.pattern, examples: [] }, + }, + }); + + const text = wrapper.text(); + expect(text).not.toContain("Example Logs"); + }); + }); + + describe("Navigation Buttons", () => { + it("should display previous button", () => { + const prevBtn = wrapper.find( + '[data-test="pattern-detail-previous-btn"]', + ); + expect(prevBtn.exists()).toBe(true); + }); + + it("should display next button", () => { + const nextBtn = wrapper.find('[data-test="pattern-detail-next-btn"]'); + expect(nextBtn.exists()).toBe(true); + }); + + it("should disable previous button on first pattern", () => { + const prevBtn = wrapper.find( + '[data-test="pattern-detail-previous-btn"]', + ); + expect(prevBtn.attributes("disabled")).toBeDefined(); + }); + + it("should enable previous button when not on first pattern", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + index: 5, + }, + }); + + const prevBtn = wrapper.find( + '[data-test="pattern-detail-previous-btn"]', + ); + expect(prevBtn.attributes("disabled")).toBeUndefined(); + }); + + it("should disable next button on last pattern", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + index: 9, + }, + totalPatterns: 10, + }); + + const nextBtn = wrapper.find('[data-test="pattern-detail-next-btn"]'); + expect(nextBtn.attributes("disabled")).toBeDefined(); + }); + + it("should emit navigate event with (false, true) when previous button clicked", async () => { + await wrapper.setProps({ + selectedPattern: { + ...mockSelectedPattern, + index: 5, + }, + }); + + const prevBtn = wrapper.find( + '[data-test="pattern-detail-previous-btn"]', + ); + await prevBtn.trigger("click"); + + expect(wrapper.emitted("navigate")).toBeTruthy(); + expect(wrapper.emitted("navigate")![0]).toEqual([false, true]); + }); + + it("should emit navigate event with (true, false) when next button clicked", async () => { + const nextBtn = wrapper.find('[data-test="pattern-detail-next-btn"]'); + await nextBtn.trigger("click"); + + expect(wrapper.emitted("navigate")).toBeTruthy(); + expect(wrapper.emitted("navigate")![0]).toEqual([true, false]); + }); + + it("should display current position in footer", () => { + const text = wrapper.text(); + // Check for the pattern position + expect(text).toMatch(/1\s+of\s+10/); + }); + }); + + describe("Dialog Visibility", () => { + it("should receive correct modelValue prop", () => { + expect(wrapper.props("modelValue")).toBe(true); + }); + + it("should render content when modelValue is true and selectedPattern exists", () => { + const card = wrapper.find(".detail-table-dialog"); + expect(card.exists()).toBe(true); + }); + + it("should not render content when selectedPattern is null", async () => { + const wrapperWithoutPattern = mount(PatternDetailsDialog, { + props: { + modelValue: true, + selectedPattern: null, + totalPatterns: 10, + }, + global: { + provide: { store }, + stubs: { + "q-dialog": { + template: '
', + props: ["modelValue"], + }, + }, + }, + }); + + const card = wrapperWithoutPattern.find(".detail-table-dialog"); + expect(card.exists()).toBe(false); + }); + }); +}); diff --git a/web/src/plugins/logs/patterns/PatternDetailsDialog.vue b/web/src/plugins/logs/patterns/PatternDetailsDialog.vue new file mode 100644 index 0000000000..d8a3422d93 --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternDetailsDialog.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/web/src/plugins/logs/patterns/PatternList.spec.ts b/web/src/plugins/logs/patterns/PatternList.spec.ts new file mode 100644 index 0000000000..96d8d52d0b --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternList.spec.ts @@ -0,0 +1,247 @@ +// Copyright 2023 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 . + +import { describe, expect, it, beforeEach } from "vitest"; +import { mount } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import * as quasar from "quasar"; +import PatternList from "./PatternList.vue"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [quasar.Notify], +}); + +describe("PatternList", () => { + let wrapper: any; + const mockPatterns = [ + { + pattern_id: "pattern-1", + template: "User * logged in from IP *", + description: "User login pattern", + frequency: 1234, + percentage: 45.67, + is_anomaly: false, + }, + { + pattern_id: "pattern-2", + template: "Error: Connection timeout to server *", + description: "Connection timeout error", + frequency: 567, + percentage: 20.32, + is_anomaly: true, + }, + { + pattern_id: "pattern-3", + template: "Request processed in * ms", + description: "Request processing time", + frequency: 890, + percentage: 31.89, + is_anomaly: false, + }, + ]; + + beforeEach(() => { + wrapper = mount(PatternList, { + props: { + patterns: mockPatterns, + loading: false, + totalLogsAnalyzed: 2791, + }, + global: { + provide: { store }, + stubs: { + PatternCard: { + template: + '
', + props: ["pattern", "index"], + }, + }, + }, + }); + }); + + it("should mount PatternList component", () => { + expect(wrapper.exists()).toBe(true); + }); + + describe("Pattern Display", () => { + it("should render q-virtual-scroll when patterns are available", () => { + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.exists()).toBe(true); + }); + + it("should pass patterns to virtual scroll", () => { + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.props("items")).toEqual(mockPatterns); + expect(virtualScroll.props("items").length).toBe(mockPatterns.length); + }); + + it("should emit open-details event when PatternCard is clicked", async () => { + // Trigger click on first pattern card + const card = wrapper.findComponent({ name: "QVirtualScroll" }); + + // Since we're using virtual scroll, we need to check if the component + // would emit the event properly + await wrapper.vm.$emit("open-details", mockPatterns[0], 0); + + expect(wrapper.emitted("open-details")).toBeTruthy(); + }); + + it("should emit add-to-search event with include action", async () => { + await wrapper.vm.$emit("add-to-search", mockPatterns[0], "include"); + + expect(wrapper.emitted("add-to-search")).toBeTruthy(); + expect(wrapper.emitted("add-to-search")![0]).toEqual([ + mockPatterns[0], + "include", + ]); + }); + + it("should emit add-to-search event with exclude action", async () => { + await wrapper.vm.$emit("add-to-search", mockPatterns[1], "exclude"); + + expect(wrapper.emitted("add-to-search")).toBeTruthy(); + expect(wrapper.emitted("add-to-search")![0]).toEqual([ + mockPatterns[1], + "exclude", + ]); + }); + }); + + describe("Loading State", () => { + beforeEach(() => { + wrapper = mount(PatternList, { + props: { + patterns: [], + loading: true, + }, + global: { + provide: { store }, + }, + }); + }); + + it("should display loading spinner when loading is true", () => { + const spinner = wrapper.findComponent({ name: "QSpinnerHourglass" }); + expect(spinner.exists()).toBe(true); + }); + + it("should display loading text", () => { + const loadingText = wrapper.text(); + expect(loadingText).toContain("Extracting patterns from logs"); + }); + + it("should not display virtual scroll when loading", () => { + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.exists()).toBe(false); + }); + }); + + describe("Empty State", () => { + beforeEach(() => { + wrapper = mount(PatternList, { + props: { + patterns: [], + loading: false, + totalLogsAnalyzed: 100, + }, + global: { + provide: { store }, + }, + }); + }); + + it("should display empty state message when no patterns", () => { + const emptyText = wrapper.text(); + expect(emptyText).toContain("No patterns found"); + }); + + it("should display total logs analyzed in empty state", () => { + const emptyText = wrapper.text(); + expect(emptyText).toContain("Only 100 logs were analyzed"); + }); + + it("should display helpful suggestion text", () => { + const emptyText = wrapper.text(); + expect(emptyText).toContain( + "Try increasing the time range or selecting a different stream", + ); + expect(emptyText).toContain( + "Pattern extraction works best with at least 1000+ logs", + ); + }); + + it("should not display total logs analyzed when not provided", () => { + wrapper = mount(PatternList, { + props: { + patterns: [], + loading: false, + }, + global: { + provide: { store }, + }, + }); + + const emptyText = wrapper.text(); + expect(emptyText).not.toContain("Only"); + expect(emptyText).not.toContain("logs were analyzed"); + }); + }); + + describe("Props Validation", () => { + it("should handle empty patterns array", () => { + wrapper = mount(PatternList, { + props: { + patterns: [], + loading: false, + }, + global: { + provide: { store }, + }, + }); + + expect(wrapper.exists()).toBe(true); + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.exists()).toBe(false); + }); + + it("should handle undefined totalLogsAnalyzed", () => { + wrapper = mount(PatternList, { + props: { + patterns: mockPatterns, + loading: false, + }, + global: { + provide: { store }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + }); + + describe("Virtual Scroll Configuration", () => { + it("should configure virtual scroll with correct slice size", () => { + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.props("virtualScrollSliceSize")).toBe("5"); + }); + + it("should pass patterns to virtual scroll items", () => { + const virtualScroll = wrapper.findComponent({ name: "QVirtualScroll" }); + expect(virtualScroll.props("items")).toEqual(mockPatterns); + }); + }); +}); diff --git a/web/src/plugins/logs/patterns/PatternList.vue b/web/src/plugins/logs/patterns/PatternList.vue new file mode 100644 index 0000000000..31c9060803 --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternList.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web/src/plugins/logs/patterns/PatternStatistics.spec.ts b/web/src/plugins/logs/patterns/PatternStatistics.spec.ts new file mode 100644 index 0000000000..44b8f9a73d --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternStatistics.spec.ts @@ -0,0 +1,201 @@ +// Copyright 2023 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 . + +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { installQuasar } from "@/test/unit/helpers/install-quasar-plugin"; +import * as quasar from "quasar"; +import PatternStatistics from "./PatternStatistics.vue"; +import store from "@/test/unit/helpers/store"; + +installQuasar({ + plugins: [quasar.Notify], +}); + +describe("PatternStatistics", () => { + let wrapper: any; + const mockStatistics = { + total_patterns_found: 42, + coverage_percentage: 87.5, + extraction_time_ms: 150, + total_logs_analyzed: 1000, + }; + + beforeEach(() => { + wrapper = mount(PatternStatistics, { + props: { + statistics: mockStatistics, + scanSize: 1000, + }, + global: { + provide: { store }, + }, + }); + }); + + it("should mount PatternStatistics component", () => { + expect(wrapper.exists()).toBe(true); + expect( + wrapper.find('[data-test="pattern-statistics"]').exists(), + ).toBe(true); + }); + + describe("Statistics Cards", () => { + it("should display logs analyzed card with correct value", () => { + const logsAnalyzedCard = wrapper.find( + '[data-test="pattern-stats-logs-analyzed-card"]', + ); + expect(logsAnalyzedCard.exists()).toBe(true); + + const scanSizeValue = wrapper.find( + '[data-test="pattern-stats-scan-size-value"]', + ); + expect(scanSizeValue.text()).toBe("1,000"); + }); + + it("should display patterns found card with correct value", () => { + const patternsFoundCard = wrapper.find( + '[data-test="pattern-stats-patterns-found-card"]', + ); + expect(patternsFoundCard.exists()).toBe(true); + + const patternsFoundValue = wrapper.find( + '[data-test="pattern-stats-patterns-found-value"]', + ); + expect(patternsFoundValue.text()).toBe("42"); + }); + + it("should display coverage card with correct value", () => { + const coverageCard = wrapper.find( + '[data-test="pattern-stats-coverage-card"]', + ); + expect(coverageCard.exists()).toBe(true); + + const coverageValue = wrapper.find( + '[data-test="pattern-stats-coverage-value"]', + ); + expect(coverageValue.text()).toBe("87.5%"); + }); + + it("should display processing time card with correct value", () => { + const processingTimeCard = wrapper.find( + '[data-test="pattern-stats-processing-time-card"]', + ); + expect(processingTimeCard.exists()).toBe(true); + + const processingTimeValue = wrapper.find( + '[data-test="pattern-stats-processing-time-value"]', + ); + expect(processingTimeValue.text()).toBe("150ms"); + }); + }); + + describe("Scan Size Edit Functionality", () => { + it("should show edit button in view mode", () => { + const editButton = wrapper.find( + '[data-test="pattern-stats-edit-scan-size-btn"]', + ); + expect(editButton.exists()).toBe(true); + + const displayMode = wrapper.find( + '[data-test="pattern-stats-scan-size-display"]', + ); + expect(displayMode.exists()).toBe(true); + }); + + it("should switch to edit mode when edit button is clicked", async () => { + const editButton = wrapper.find( + '[data-test="pattern-stats-edit-scan-size-btn"]', + ); + await editButton.trigger("click"); + + const editMode = wrapper.find( + '[data-test="pattern-stats-scan-size-edit"]', + ); + expect(editMode.exists()).toBe(true); + + const input = wrapper.find( + '[data-test="pattern-stats-scan-size-input"]', + ); + expect(input.exists()).toBe(true); + }); + + it("should emit update event when scan size is changed", async () => { + // Click edit button to enter edit mode + const editButton = wrapper.find( + '[data-test="pattern-stats-edit-scan-size-btn"]', + ); + await editButton.trigger("click"); + await wrapper.vm.$nextTick(); + + // Directly set the localScanSize ref in the component + wrapper.vm.localScanSize = 2000; + await wrapper.vm.$nextTick(); + + // Call updateScanSize method directly + wrapper.vm.updateScanSize(); + await wrapper.vm.$nextTick(); + + // Check if update:scanSize event was emitted + expect(wrapper.emitted("update:scanSize")).toBeTruthy(); + expect(wrapper.emitted("update:scanSize")![0]).toEqual([2000]); + }); + + it("should cancel edit mode on escape key", async () => { + // Click edit button + const editButton = wrapper.find( + '[data-test="pattern-stats-edit-scan-size-btn"]', + ); + await editButton.trigger("click"); + + // Find input and press escape + const input = wrapper.find( + '[data-test="pattern-stats-scan-size-input"]', + ); + await input.trigger("keyup.escape"); + + // Should return to view mode + await wrapper.vm.$nextTick(); + const displayMode = wrapper.find( + '[data-test="pattern-stats-scan-size-display"]', + ); + expect(displayMode.exists()).toBe(true); + }); + }); + + describe("Empty Statistics", () => { + it("should display zero values when statistics are null", async () => { + await wrapper.setProps({ + statistics: null, + scanSize: 500, + }); + + const patternsFoundValue = wrapper.find( + '[data-test="pattern-stats-patterns-found-value"]', + ); + expect(patternsFoundValue.text()).toBe("0"); + + const coverageValue = wrapper.find( + '[data-test="pattern-stats-coverage-value"]', + ); + expect(coverageValue.text()).toBe("0.0%"); + + const processingTimeValue = wrapper.find( + '[data-test="pattern-stats-processing-time-value"]', + ); + expect(processingTimeValue.text()).toBe("0ms"); + }); + }); +}); diff --git a/web/src/plugins/logs/patterns/PatternStatistics.vue b/web/src/plugins/logs/patterns/PatternStatistics.vue new file mode 100644 index 0000000000..47b1d819af --- /dev/null +++ b/web/src/plugins/logs/patterns/PatternStatistics.vue @@ -0,0 +1,206 @@ + + + + + + + diff --git a/web/src/styles/_variables.scss b/web/src/styles/_variables.scss index b4608b9f32..1045ee6765 100644 --- a/web/src/styles/_variables.scss +++ b/web/src/styles/_variables.scss @@ -312,6 +312,11 @@ $json-object-dark: #D1D5DB; // Light gray --o2-focus-ring: #{$o2-iris-1200}; // Focus state indicator --o2-hover-shadow: #dddddd; + --o2-hover-gray: color-mix(in srgb, #{$theme-color} 2%, rgb(244, 244, 244)); + + --o2-bg-gray: color-mix(in srgb, #{$theme-color} 10%, rgb(237, 237, 237)); + + --o2-scrollbar-track-bg: rgba(0, 0, 0, 0.01); --o2-scrollbar-track-hover-bg: rgba(0, 0, 0, 0.01); --o2-scrollbar-thumb-bg: color-mix(in srgb, #{$primary} 20%, white); @@ -393,10 +398,13 @@ $json-object-dark: #D1D5DB; // Light gray // Interactive Elements --o2-primary-color: #{$o2-dark-iris-1000}; // Primary actions --o2-primary-foreground: #{$o2-dark-gray-100}; // Text on primary - --o2-hover-accent: color-mix(in srgb, #{$theme-color} 25%, rgb(0, 0, 0)); // Hover accents + --o2-hover-accent: color-mix(in srgb, #{$theme-color} 20%, rgb(39, 39, 39)); // Hover accents --o2-destructive-foreground: #{$o2-dark-gray-100}; // Destructive text --o2-focus-ring: #{$o2-dark-iris-600}; // Focus indicators --o2-hover-shadow: rgba(0, 0, 0, 0.6); + --o2-hover-gray: color-mix(in srgb, #{$theme-color} 8%, rgb(38, 38, 38)); + + --o2-bg-gray: color-mix(in srgb, #{$theme-color} 5%, rgb(42, 42, 42)); --o2-scrollbar-track-bg: rgba(0, 0, 0, 0.01); --o2-scrollbar-track-hover-bg: rgba(0, 0, 0, 0.01); diff --git a/web/src/styles/app.scss b/web/src/styles/app.scss index 8e61c126b8..655d9ac8de 100644 --- a/web/src/styles/app.scss +++ b/web/src/styles/app.scss @@ -482,6 +482,10 @@ body { line-height: 15.72px; } +.o2-monospace-font { + font-family: "Monaco", "Menlo", "Courier New", monospace; +} + .o2-card-wrapper { box-shadow: none; border: 1px solid #E4E7EC; diff --git a/web/src/styles/logs/search-result.scss b/web/src/styles/logs/search-result.scss index 76d4090837..8ad2451e46 100644 --- a/web/src/styles/logs/search-result.scss +++ b/web/src/styles/logs/search-result.scss @@ -259,4 +259,14 @@ border: none !important; } } +} + +// Pattern block actions - show on hover +.pattern-block-actions { + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.q-card:hover .pattern-block-actions { + opacity: 1; } \ No newline at end of file