fix: Log patterns refactoring (#9026)

This commit is contained in:
Omkar Kesarkhane
2025-11-10 15:07:58 +05:30
committed by GitHub
parent 8ee59b6213
commit da80cef2af
13 changed files with 1810 additions and 675 deletions
+29 -670
View File
@@ -287,7 +287,7 @@ color="warning" size="xs"></q-icon> Error while
<!-- Patterns View -->
<div
v-if="searchObj.meta.logsVisualizeToggle === 'patterns'"
style="display: flex; flex-direction: column"
class="tw-flex tw-flex-col"
:class="[
!searchObj.meta.showHistogram ||
(searchObj.meta.showHistogram &&
@@ -297,379 +297,23 @@ color="warning" size="xs"></q-icon> Error while
]"
>
<!-- Statistics Bar -->
<div
<PatternStatistics
v-if="patternsState?.patterns?.statistics"
class="q-pa-md"
:class="store.state.theme === 'dark' ? 'bg-dark' : 'bg-grey-2'"
style="border-bottom: 1px solid; flex-shrink: 0"
:style="{
borderColor: store.state.theme === 'dark' ? '#3a3a3a' : '#e0e0e0',
}"
>
<div class="row q-col-gutter-md">
<div class="col-3">
<q-card
flat
:class="store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-white'"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Logs Analyzed
</div>
<div class="row items-center q-mt-xs q-gutter-xs">
<!-- View Mode -->
<div
v-if="!isEditingScanSize"
class="row items-center no-wrap tw-m-0 tw-pl-1"
>
<div
class="tw-text-[1.5rem] tw-leading-[2rem] text-weight-bold text-primary tw-h-fit tw-pr-1"
>
{{ patternsState.scanSize.toLocaleString() }}
</div>
<q-btn
flat
round
dense
size="xs"
icon="edit"
@click="toggleEditScanSize"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
<q-tooltip>Edit scan size</q-tooltip>
</q-btn>
</div>
:statistics="patternsState?.patterns?.statistics"
:scanSize="patternsState.scanSize"
@update:scanSize="patternsState.scanSize = $event"
/>
<!-- Edit Mode -->
<div
v-else
class="row items-center no-wrap logs-analyzed-input"
style="gap: 0.25rem"
>
<q-input
v-model.number="patternsState.scanSize"
type="number"
dense
outlined
:min="100"
:max="20000"
class="!tw-text-[1.2rem] text-weight-bold"
style="max-width: 120px"
autofocus
@keyup.escape="isEditingScanSize = false"
@blur="isEditingScanSize = false"
/>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-3">
<q-card
flat
:class="store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-white'"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Patterns Found
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary">
{{
patternsState?.patterns?.statistics
?.total_patterns_found || 0
}}
</div>
</q-card-section>
</q-card>
</div>
<div class="col-3">
<q-card
flat
:class="store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-white'"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Coverage
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary">
{{
(
patternsState?.patterns?.statistics
?.coverage_percentage || 0
).toFixed(1)
}}%
</div>
</q-card-section>
</q-card>
</div>
<div class="col-3">
<q-card
flat
:class="store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-white'"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Processing Time
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary">
{{
patternsState?.patterns?.statistics?.extraction_time_ms ||
0
}}ms
</div>
</q-card-section>
</q-card>
</div>
</div>
</div>
<!-- Patterns List with Virtual Scroll for Performance -->
<div
v-if="patternsState?.patterns?.patterns?.length > 0"
style="flex: 1; overflow: hidden"
>
<q-virtual-scroll
:items="patternsState?.patterns?.patterns"
virtual-scroll-slice-size="5"
v-slot="{ item: pattern, index }"
style="height: 100%"
class="q-pa-md"
>
<q-card
flat
bordered
:class="store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-white'"
class="q-mb-md cursor-pointer"
@click="openPatternDetails(pattern, index)"
>
<!-- Header with Rank and Stats - Compact -->
<q-card-section
class="q-pa-md"
:class="
store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-1'
"
>
<div class="row items-center q-col-gutter-md no-wrap">
<div class="col-auto">
<q-avatar
size="2rem"
color="primary"
text-color="white"
class="text-weight-bold"
>
{{ index + 1 }}
</q-avatar>
</div>
<div class="col">
<!-- Template shown first with info icon -->
<div class="tw-flex tw-items-center q-mb-xs">
<div
class="text-body2 ellipsis"
:class="
store.state.theme === 'dark'
? 'text-grey-4'
: 'text-grey-8'
"
style="
font-family:
&quot;Monaco&quot;, &quot;Menlo&quot;,
&quot;Courier New&quot;, monospace;
font-size: 0.8125rem;
"
>
{{ pattern.template }}
</div>
<q-icon
:name="outlinedInfo"
size="17px"
class="q-ml-xs cursor-pointer"
:class="store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'"
>
<q-tooltip
anchor="center right"
self="center left"
max-width="300px"
style="font-size: 12px;"
>
<div class="text-weight-bold q-mb-xs">Template vs Description</div>
<div class="q-mb-xs"><strong>Template:</strong> {{ pattern.template }}</div>
<div><strong>Description:</strong> {{ pattern.description }}</div>
</q-tooltip>
</q-icon>
</div>
<!-- Occurrences and percentage on second line -->
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-6'
: 'text-grey-6'
"
>
<span class="text-weight-bold text-primary">{{
pattern.frequency.toLocaleString()
}}</span>
occurrences
<span class="q-mx-xs">•</span>
<span class="text-weight-bold text-primary"
>{{ pattern.percentage.toFixed(2) }}%</span
>
<span
v-if="pattern.is_anomaly"
class="text-negative text-weight-bold q-ml-sm"
>⚠️ ANOMALY</span
>
</div>
</div>
<div class="col-auto row items-center q-gutter-xs">
<q-btn
size="0.375rem"
@click.stop="addPatternToSearch(pattern, 'include')"
title="Include pattern in search"
round
flat
dense
>
<q-icon color="currentColor">
<EqualIcon></EqualIcon>
</q-icon>
</q-btn>
<q-btn
size="0.375rem"
@click.stop="addPatternToSearch(pattern, 'exclude')"
title="Exclude pattern from search"
round
flat
dense
>
<q-icon color="currentColor">
<NotEqualIcon></NotEqualIcon>
</q-icon>
</q-btn>
<q-icon
name="info"
size="1.0625rem"
class="cursor-pointer"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
<q-tooltip
>Show details ({{
pattern.examples?.[0]?.variables
? Object.keys(pattern.examples[0].variables).length
: 0
}}
variables,
{{ pattern.examples?.length || 0 }} examples)</q-tooltip
>
</q-icon>
</div>
</div>
</q-card-section>
</q-card>
</q-virtual-scroll>
</div>
<div
v-else-if="searchObj.loading"
style="
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
<!-- Patterns List -->
<PatternList
:patterns="patternsState?.patterns?.patterns || []"
:loading="searchObj.loading"
:totalLogsAnalyzed="
patternsState?.patterns?.statistics?.total_logs_analyzed
"
>
<q-spinner-hourglass color="primary" size="3.125rem" />
<div
class="q-mt-md text-body2"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Extracting patterns from logs...
</div>
</div>
<div
v-else
style="
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.25rem;
text-align: center;
"
>
<div style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.3">
📊
</div>
<div
class="text-h6 q-mb-sm"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
No patterns found
</div>
<div
class="text-body2"
:class="
store.state.theme === 'dark' ? 'text-grey-6' : 'text-grey-8'
"
style="max-width: 31.25rem"
>
<div
v-if="patternsState?.patterns?.statistics?.total_logs_analyzed"
>
Only
{{ patternsState?.patterns?.statistics?.total_logs_analyzed }}
logs were analyzed.
</div>
<div class="q-mt-sm">
Try increasing the time range or selecting a different stream with
more log data.
<br />Pattern extraction works best with at least 1000+ logs.
</div>
</div>
</div>
@open-details="openPatternDetails"
@add-to-search="addPatternToSearch"
/>
</div>
<q-dialog
@@ -720,289 +364,12 @@ color="warning" size="xs"></q-icon> Error while
</q-dialog>
<!-- Pattern Details Drawer -->
<q-dialog
data-test="pattern-detail-dialog"
<PatternDetailsDialog
v-model="showPatternDetails"
position="right"
full-height
maximized
>
<q-card
v-if="selectedPattern"
class="detail-table-dialog"
:class="store.state.theme === 'dark' ? 'bg-dark' : 'bg-white'"
style="width: 70vw; max-width: 70vw"
>
<!-- Header -->
<q-card-section
class="q-pa-md"
:class="store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-1'"
>
<div class="row items-center">
<div class="col">
<div class="text-h6">Pattern Details</div>
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Pattern {{ (selectedPattern as any).index + 1 }} of
{{ patternsState?.patterns?.patterns?.length || 0 }}
</div>
</div>
<div class="col-auto">
<q-btn
outline
no-caps
padding="xs md"
icon="chevron_left"
label="Previous"
@click="navigatePatternDetail(false, true)"
:disable="(selectedPattern as any).index === 0"
:class="
store.state.theme === 'dark'
? 'text-grey-5 border-grey-5'
: 'text-grey-7 border-grey-7'
"
class="q-mr-sm"
/>
<q-btn
outline
no-caps
padding="xs md"
icon-right="chevron_right"
label="Next"
@click="navigatePatternDetail(true, false)"
:disable="
(selectedPattern as any).index >=
(patternsState?.patterns?.patterns?.length || 0) - 1
"
:class="
store.state.theme === 'dark'
? 'text-grey-5 border-grey-5'
: 'text-grey-7 border-grey-7'
"
class="q-mr-sm"
/>
<q-btn
flat
round
dense
icon="close"
@click="showPatternDetails = false"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
/>
</div>
</div>
</q-card-section>
<q-separator />
<!-- Content -->
<q-card-section
class="q-pa-md"
style="height: calc(100vh - 100px); overflow-y: auto"
>
<!-- Statistics -->
<div class="q-mb-lg">
<div class="text-subtitle2 text-weight-medium q-mb-md">
Statistics
</div>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-card
flat
bordered
:class="
store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-grey-2'
"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Occurrences
</div>
<div
class="text-h5 text-weight-bold text-primary q-mt-xs"
>
{{
(
selectedPattern as any
).pattern.frequency.toLocaleString()
}}
</div>
</q-card-section>
</q-card>
</div>
<div class="col-6">
<q-card
flat
bordered
:class="
store.state.theme === 'dark' ? 'bg-grey-9' : 'bg-grey-2'
"
>
<q-card-section class="q-pa-md">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Percentage
</div>
<div
class="text-h5 text-weight-bold text-primary q-mt-xs"
>
{{
(selectedPattern as any).pattern.percentage.toFixed(
2,
)
}}%
</div>
</q-card-section>
</q-card>
</div>
</div>
<div
v-if="(selectedPattern as any).pattern.is_anomaly"
class="q-mt-md"
>
<q-banner class="bg-negative text-white">
<template v-slot:avatar>
<q-icon name="warning" size="md" />
</template>
This pattern is detected as an anomaly
</q-banner>
</div>
</div>
<!-- Template -->
<div class="q-mb-lg">
<div class="text-subtitle2 text-weight-medium q-mb-md">
Pattern Template
</div>
<div
class="q-pa-md"
:class="
store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-2'
"
style="
font-family:
&quot;Monaco&quot;, &quot;Menlo&quot;,
&quot;Courier New&quot;, monospace;
font-size: 0.8125rem;
line-height: 1.6;
border-radius: 0.25rem;
border-left: 0.25rem solid;
word-break: break-all;
white-space: pre-wrap;
border-color: var(--q-primary);
"
>
{{ (selectedPattern as any).pattern.template }}
</div>
</div>
<!-- Variables -->
<div
v-if="
(selectedPattern as any).pattern.variables &&
(selectedPattern as any).pattern.variables.length > 0
"
class="q-mb-lg"
>
<div class="text-subtitle2 text-weight-medium q-mb-md">
Variables ({{
(selectedPattern as any).pattern.variables.length
}})
</div>
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem">
<q-chip
v-for="variable in (selectedPattern as any).pattern.variables"
:key="variable.index"
:class="
store.state.theme === 'dark' ? 'bg-grey-8' : 'bg-grey-3'
"
>
<span class="text-weight-bold text-primary">{{
variable.name || "var_" + variable.index
}}</span>
<span
class="q-mx-xs"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>•</span
>
<span
:class="
store.state.theme === 'dark'
? 'text-grey-4'
: 'text-grey-7'
"
>{{ variable.var_type || "unknown" }}</span
>
</q-chip>
</div>
</div>
<!-- Example Logs -->
<div
v-if="
(selectedPattern as any).pattern.examples &&
(selectedPattern as any).pattern.examples.length > 0
"
class="q-mb-lg"
>
<div class="text-subtitle2 text-weight-medium q-mb-md">
Example Logs ({{
(selectedPattern as any).pattern.examples.length
}})
</div>
<div
v-for="(example, exIdx) in (selectedPattern as any).pattern
.examples"
:key="exIdx"
class="q-pa-md q-mb-md"
:class="
store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-1'
"
style="
font-family:
&quot;Monaco&quot;, &quot;Menlo&quot;,
&quot;Courier New&quot;, monospace;
font-size: 0.75rem;
line-height: 1.6;
border-radius: 0.25rem;
word-break: break-all;
white-space: pre-wrap;
border-left: 0.1875rem solid;
"
:style="{
borderColor:
store.state.theme === 'dark' ? '#3a3a3a' : '#e0e0e0',
}"
>
{{ example.log_message }}
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
:selectedPattern="selectedPattern"
:totalPatterns="patternsState?.patterns?.patterns?.length || 0"
@navigate="navigatePatternDetail"
/>
</div>
</div>
</template>
@@ -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: {
+1 -4
View File
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
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: '<div class="equal-icon"></div>' },
NotEqualIcon: { template: '<div class="not-equal-icon"></div>' },
},
},
});
});
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");
});
});
});
@@ -0,0 +1,178 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<q-card
flat
class="tw-mb-[0.375rem] cursor-pointer tw-border tw-border-solid tw-border-[var(--o2-border-color)] hover:tw-bg-[var(--o2-hover-gray)]"
@click="$emit('click', pattern, index)"
:data-test="`pattern-card-${index}`"
>
<!-- Header with Rank and Stats - Compact -->
<q-card-section class="tw-p-[0.625rem]">
<div class="row items-center q-col-gutter-md no-wrap">
<div class="col-auto">
<q-avatar
size="2rem"
color="primary"
text-color="white"
class="text-weight-bold"
:data-test="`pattern-card-${index}-rank`"
>
{{ index + 1 }}
</q-avatar>
</div>
<div class="col">
<!-- Template shown first with info icon -->
<div class="tw-flex tw-items-center q-mb-xs">
<div
class="text-body2 ellipsis tw-text-[0.8rem] o2-monospace-font"
:class="
store.state.theme === 'dark' ? 'text-grey-4' : 'text-grey-8'
"
:data-test="`pattern-card-${index}-template`"
>
{{ pattern.template }}
</div>
<q-icon
:name="outlinedInfo"
size="1rem"
class="q-ml-xs cursor-pointer"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
:data-test="`pattern-card-${index}-info-icon`"
>
<q-tooltip
anchor="center right"
self="center left"
max-width="18.75rem"
class="tw-text-[0.75rem]"
>
<div class="text-weight-bold q-mb-xs">
Template vs Description
</div>
<div class="q-mb-xs">
<strong>Template:</strong> {{ pattern.template }}
</div>
<div>
<strong>Description:</strong> {{ pattern.description }}
</div>
</q-tooltip>
</q-icon>
</div>
<!-- Occurrences and percentage on second line -->
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-6' : 'text-grey-6'
"
>
<span
class="text-weight-bold text-primary"
:data-test="`pattern-card-${index}-frequency`"
>
{{ pattern.frequency.toLocaleString() }}
</span>
occurrences
<span class="q-mx-xs">•</span>
<span
class="text-weight-bold text-primary"
:data-test="`pattern-card-${index}-percentage`"
>{{ pattern.percentage.toFixed(2) }}%</span
>
<span
v-if="pattern.is_anomaly"
class="text-negative text-weight-bold q-ml-sm"
:data-test="`pattern-card-${index}-anomaly-badge`"
>⚠️ ANOMALY</span
>
</div>
</div>
<div
class="col-auto row items-center q-gutter-xs pattern-block-actions"
>
<q-btn
size="0.375rem"
@click.stop="$emit('include', pattern)"
title="Include pattern in search"
round
flat
dense
:data-test="`pattern-card-${index}-include-btn`"
>
<q-icon color="currentColor">
<EqualIcon></EqualIcon>
</q-icon>
</q-btn>
<q-btn
size="0.375rem"
@click.stop="$emit('exclude', pattern)"
title="Exclude pattern from search"
round
flat
dense
:data-test="`pattern-card-${index}-exclude-btn`"
>
<q-icon class="tw-text-[var(--o2-text-color)]">
<NotEqualIcon></NotEqualIcon>
</q-icon>
</q-btn>
<q-icon
name="info"
size="1.0625rem"
class="cursor-pointer"
:data-test="`pattern-card-${index}-details-icon`"
>
<q-tooltip
>Show details ({{
pattern.examples?.[0]?.variables
? Object.keys(pattern.examples[0].variables).length
: 0
}}
variables,
{{ pattern.examples?.length || 0 }} examples)</q-tooltip
>
</q-icon>
</div>
</div>
</q-card-section>
</q-card>
</template>
<script setup lang="ts">
import { useStore } from "vuex";
import { outlinedInfo } from "@quasar/extras/material-icons-outlined";
import EqualIcon from "@/components/icons/EqualIcon.vue";
import NotEqualIcon from "@/components/icons/NotEqualIcon.vue";
defineProps<{
pattern: any;
index: number;
}>();
defineEmits<{
(e: "click", pattern: any, index: number): void;
(e: "include", pattern: any): void;
(e: "exclude", pattern: any): void;
}>();
const store = useStore();
</script>
<style scoped lang="scss">
@import "@/styles/logs/search-result.scss";
</style>
@@ -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 <http://www.gnu.org/licenses/>.
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: '<div class="q-dialog"><slot></slot></div>',
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: '<div class="q-dialog"><slot></slot></div>',
props: ["modelValue"],
},
},
},
});
const card = wrapperWithoutPattern.find(".detail-table-dialog");
expect(card.exists()).toBe(false);
});
});
});
@@ -0,0 +1,294 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<q-dialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
position="right"
full-height
maximized
>
<q-card
v-if="selectedPattern"
class="column full-height no-wrap detail-table-dialog !tw-w-[90vw] !tw-max-w-[90vw] tw-border-t-4 tw-border-t-[var(--q-primary)] tw-border-solid"
>
<!-- Header -->
<q-card-section class="q-px-md q-pb-sm">
<div class="row items-center no-wrap">
<div class="col">
<div class="text-body1 text-bold">Pattern Details</div>
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Pattern {{ selectedPattern.index + 1 }} of {{ totalPatterns }}
</div>
</div>
<div class="col-auto">
<q-btn
v-close-popup="true"
round
flat
icon="cancel"
data-test="close-pattern-dialog"
/>
</div>
</div>
</q-card-section>
<q-separator />
<!-- Content - Single Scrollable View -->
<q-card-section
class="tw-py-[0.375rem] tw-px-[0.625rem] tw-flex-1 tw-overflow-y-auto"
>
<!-- Statistics -->
<div class="tw-mb-[1rem]">
<div class="text-subtitle2 text-weight-medium tw-mb-[0.375rem]">
Statistics
</div>
<div class="row q-col-gutter-md">
<div class="col-6">
<q-card
flat
class="tw-bg-[var(--o2-card-bg)] tw-border tw-border-solid tw-border-[var(--o2-border-color)]"
>
<q-card-section class="tw-p-[0.375rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Occurrences
</div>
<div
class="text-h5 text-weight-bold text-primary q-mt-xs"
>
{{
selectedPattern.pattern.frequency.toLocaleString()
}}
</div>
</q-card-section>
</q-card>
</div>
<div class="col-6">
<q-card flat class="tw-bg-[var(--o2-card-bg)] tw-border tw-border-solid tw-border-[var(--o2-border-color)]">
<q-card-section class="tw-p-[0.375rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark'
? 'text-grey-5'
: 'text-grey-7'
"
>
Percentage
</div>
<div
class="text-h5 text-weight-bold text-primary q-mt-xs"
>
{{ selectedPattern.pattern.percentage.toFixed(2) }}%
</div>
</q-card-section>
</q-card>
</div>
</div>
<div
v-if="selectedPattern.pattern.is_anomaly"
class="q-mt-md"
>
<q-banner class="bg-negative text-white">
<template v-slot:avatar>
<q-icon name="warning" size="md" />
</template>
This pattern is detected as an anomaly
</q-banner>
</div>
</div>
<!-- Description -->
<div class="tw-mb-[1rem]">
<div class="text-subtitle2 text-weight-medium tw-mb-[0.375rem]">
Description
</div>
<div
class="tw-px-[0.625rem] tw-py-[0.375rem] tw-rounded tw-border-l-[0.25rem] tw-border-solid tw-border-l-[var(--q-primary)]"
:class="
store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-2'
"
>
{{
selectedPattern.pattern.description ||
"No description available"
}}
</div>
</div>
<!-- Pattern Template -->
<div class="tw-mb-[1rem]">
<div class="text-subtitle2 text-weight-medium tw-mb-[0.375rem]">
Pattern Template
</div>
<div
class="tw-px-[0.625rem] tw-py-[0.375rem] o2-monospace-font tw-text-[0.8125rem] tw-leading-[1.6] tw-rounded tw-border-l-[0.25rem] tw-border-solid tw-border-l-[var(--q-primary)] tw-break-all tw-whitespace-pre-wrap"
:class="
store.state.theme === 'dark' ? 'bg-grey-10' : 'bg-grey-2'
"
>
{{ selectedPattern.pattern.template }}
</div>
</div>
<!-- Variables -->
<div
v-if="
selectedPattern.pattern.variables &&
selectedPattern.pattern.variables.length > 0
"
class="tw-mb-[1rem]"
>
<div class="text-subtitle2 text-weight-medium tw-mb-[0.375rem]">
Variables ({{ selectedPattern.pattern.variables.length }})
</div>
<q-table
:rows="selectedPattern.pattern.variables"
:columns="variableColumns"
:row-key="(row: any) => 'var_' + row.index"
:rows-per-page-options="[0]"
class="q-table o2-quasar-table o2-row-md tw-w-full tw-border tw-border-solid tw-border-[var(--o2-border-color)]"
dense
>
<template v-slot:body-cell-name="props">
<q-td
class="text-left text-weight-bold text-primary"
>
{{ props.row.name || "var_" + props.row.index }}
</q-td>
</template>
<template v-slot:body-cell-type="props">
<q-td class="text-left">
<q-chip
size="sm"
:class="
store.state.theme === 'dark' ? 'bg-grey-8' : 'bg-grey-3'
"
>
{{ props.row.var_type || "unknown" }}
</q-chip>
</q-td>
</template>
</q-table>
</div>
<!-- Example Logs -->
<div
v-if="
selectedPattern.pattern.examples &&
selectedPattern.pattern.examples.length > 0
"
class="tw-mb-[1rem]"
>
<div class="text-subtitle2 text-weight-medium tw-mb-[0.375rem]">
Example Logs ({{ selectedPattern.pattern.examples.length }})
</div>
<div
v-for="(example, exIdx) in selectedPattern.pattern.examples"
:key="exIdx"
class="tw-px-[0.625rem] tw-py-[0.375rem] tw-mb-[0.375rem] o2-monospace-font tw-text-[0.75rem] tw-leading-[1.6] tw-rounded tw-break-all tw-whitespace-pre-wrap tw-border-l-[0.1875rem] tw-border-solid"
:class="[
store.state.theme === 'dark' ? 'bg-grey-10 tw-border-l-[#3a3a3a]' : 'bg-grey-1 tw-border-l-[#e0e0e0]'
]"
>
{{ example.log_message }}
</div>
</div>
</q-card-section>
<!-- Footer Navigation -->
<q-separator />
<q-card-section class="tw-px-[0.625rem] tw-py-[0.375rem]">
<div class="row items-center no-wrap justify-between">
<div class="col-auto">
<q-btn
data-test="pattern-detail-previous-btn"
class="o2-secondary-button tw-h-[36px]"
no-caps
:disabled="selectedPattern.index === 0"
@click="$emit('navigate', false, true)"
icon="navigate_before"
label="Previous"
/>
</div>
<div class="col-auto text-center">
<span class="text-caption text-grey-7">
{{ selectedPattern.index + 1 }} of {{ totalPatterns }}
</span>
</div>
<div class="col-auto">
<q-btn
data-test="pattern-detail-next-btn"
class="o2-secondary-button tw-h-[36px]"
no-caps
:disabled="selectedPattern.index >= totalPatterns - 1"
@click="$emit('navigate', true, false)"
icon-right="navigate_next"
label="Next"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { useStore } from "vuex";
defineProps<{
modelValue: boolean;
selectedPattern: { pattern: any; index: number } | null;
totalPatterns: number;
}>();
defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "navigate", next: boolean, prev: boolean): void;
}>();
const store = useStore();
const variableColumns = [
{
name: "name",
label: "Variable Name",
field: "name",
align: "left",
},
{
name: "type",
label: "Type",
field: "var_type",
align: "left",
},
];
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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:
'<div :data-test="`pattern-card-stub-${index}`"><slot></slot></div>',
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);
});
});
});
@@ -0,0 +1,95 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="tw-flex-1 tw-overflow-hidden tw-py-[0.375rem]">
<!-- Patterns List with Virtual Scroll -->
<q-virtual-scroll
v-if="patterns?.length > 0"
:items="patterns"
virtual-scroll-slice-size="5"
v-slot="{ item: pattern, index }"
class="tw-h-full tw-pl-[0.375rem] tw-pr-[0.625rem]"
>
<PatternCard
:pattern="pattern"
:index="index"
@click="$emit('open-details', pattern, index)"
@include="$emit('add-to-search', pattern, 'include')"
@exclude="$emit('add-to-search', pattern, 'exclude')"
/>
</q-virtual-scroll>
<!-- Loading State -->
<div
v-else-if="loading"
class="tw-flex-1 tw-flex tw-flex-col tw-items-center tw-justify-center"
>
<q-spinner-hourglass color="primary" size="3.125rem" />
<div
class="q-mt-md text-body2"
:class="store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'"
>
Extracting patterns from logs...
</div>
</div>
<!-- Empty State -->
<div
v-else
class="tw-flex-1 tw-flex tw-flex-col tw-items-center tw-justify-center tw-p-[1.25rem] tw-text-center"
>
<div class="tw-text-[3rem] tw-mb-[1rem] tw-opacity-30">📊</div>
<div
class="text-h6 q-mb-sm"
:class="store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'"
>
No patterns found
</div>
<div
class="text-body2 tw-max-w-[31.25rem]"
:class="store.state.theme === 'dark' ? 'text-grey-6' : 'text-grey-8'"
>
<div v-if="totalLogsAnalyzed">
Only {{ totalLogsAnalyzed }} logs were analyzed.
</div>
<div class="q-mt-sm">
Try increasing the time range or selecting a different stream with
more log data.
<br />Pattern extraction works best with at least 1000+ logs.
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useStore } from "vuex";
import PatternCard from "./PatternCard.vue";
defineProps<{
patterns: any[];
loading: boolean;
totalLogsAnalyzed?: number;
}>();
defineEmits<{
(e: "open-details", pattern: any, index: number): void;
(e: "add-to-search", pattern: any, action: "include" | "exclude"): void;
}>();
const store = useStore();
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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");
});
});
});
@@ -0,0 +1,206 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="tw-py-[0.375rem] tw-shrink-0 tw-bg-[var(--o2-bg-gray)]" data-test="pattern-statistics">
<div class="row q-col-gutter-md !tw-mx-[0.375rem]">
<!-- Logs Analyzed Card -->
<div class="col-3 !tw-pl-0">
<q-card
flat
class="tw-bg-[var(--o2-card-bg)]"
data-test="pattern-stats-logs-analyzed-card"
>
<q-card-section class="tw-py-[0.375rem] tw-px-[0.625rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Logs Analyzed
</div>
<div class="row items-center q-mt-xs q-gutter-xs">
<!-- View Mode -->
<div
v-if="!isEditingScanSize"
class="row items-center no-wrap tw-m-0 tw-pl-1"
data-test="pattern-stats-scan-size-display"
>
<div
class="tw-text-[1.5rem] tw-leading-[2rem] text-weight-bold text-primary tw-h-fit tw-pr-1"
data-test="pattern-stats-scan-size-value"
>
{{ scanSize.toLocaleString() }}
</div>
<q-btn
flat
round
dense
size="xs"
icon="edit"
@click="toggleEditScanSize"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
data-test="pattern-stats-edit-scan-size-btn"
>
<q-tooltip>Edit scan size</q-tooltip>
</q-btn>
</div>
<!-- Edit Mode -->
<div
v-else
class="row items-center no-wrap logs-analyzed-input tw-gap-[0.25rem]"
data-test="pattern-stats-scan-size-edit"
>
<q-input
v-model.number="localScanSize"
type="number"
dense
outlined
:min="100"
:max="20000"
class="!tw-text-[1.2rem] text-weight-bold tw-max-w-[7.5rem]"
autofocus
@keyup.escape="cancelEditScanSize"
@blur="updateScanSize"
data-test="pattern-stats-scan-size-input"
/>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- Patterns Found Card -->
<div class="col-3 !tw-pl-[0.375rem]">
<q-card
flat
class="tw-bg-[var(--o2-card-bg)]"
data-test="pattern-stats-patterns-found-card"
>
<q-card-section class="tw-py-[0.375rem] tw-px-[0.625rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Patterns Found
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary" data-test="pattern-stats-patterns-found-value">
{{ statistics?.total_patterns_found || 0 }}
</div>
</q-card-section>
</q-card>
</div>
<!-- Coverage Card -->
<div class="col-3 !tw-pl-[0.375rem]">
<q-card
flat
class="tw-bg-[var(--o2-card-bg)]"
data-test="pattern-stats-coverage-card"
>
<q-card-section class="tw-py-[0.375rem] tw-px-[0.625rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Coverage
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary" data-test="pattern-stats-coverage-value">
{{ (statistics?.coverage_percentage || 0).toFixed(1) }}%
</div>
</q-card-section>
</q-card>
</div>
<!-- Processing Time Card -->
<div class="col-3 !tw-pl-[0.375rem]">
<q-card
flat
class="tw-bg-[var(--o2-card-bg)]"
data-test="pattern-stats-processing-time-card"
>
<q-card-section class="tw-py-[0.375rem] tw-px-[0.625rem]">
<div
class="text-caption"
:class="
store.state.theme === 'dark' ? 'text-grey-5' : 'text-grey-7'
"
>
Processing Time
</div>
<div class="text-h5 text-weight-bold q-mt-xs text-primary" data-test="pattern-stats-processing-time-value">
{{ statistics?.extraction_time_ms || 0 }}ms
</div>
</q-card-section>
</q-card>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useStore } from "vuex";
const props = defineProps<{
statistics: any;
scanSize: number;
}>();
const emit = defineEmits<{
(e: "update:scanSize", value: number): void;
}>();
const store = useStore();
const isEditingScanSize = ref(false);
const localScanSize = ref(props.scanSize);
const toggleEditScanSize = () => {
isEditingScanSize.value = true;
localScanSize.value = props.scanSize;
};
const cancelEditScanSize = () => {
isEditingScanSize.value = false;
localScanSize.value = props.scanSize;
};
const updateScanSize = () => {
isEditingScanSize.value = false;
if (localScanSize.value !== props.scanSize) {
emit("update:scanSize", localScanSize.value);
}
};
watch(
() => props.scanSize,
(newValue) => {
localScanSize.value = newValue;
},
);
</script>
<style scoped lang="scss">
@import "@/styles/logs/search-result.scss";
</style>
+9 -1
View File
@@ -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);
+4
View File
@@ -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;
+10
View File
@@ -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;
}