mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-06 00:09:22 +00:00
fix: Log patterns refactoring (#9026)
This commit is contained in:
@@ -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:
|
||||
"Monaco", "Menlo",
|
||||
"Courier New", 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:
|
||||
"Monaco", "Menlo",
|
||||
"Courier New", 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:
|
||||
"Monaco", "Menlo",
|
||||
"Courier New", 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: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user