This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/2.0.0 by this push:
     new db3adbf8b6 Compact log attribute operator actions
db3adbf8b6 is described below

commit db3adbf8b6dc76d7ed98a25537ac75bedcf28031
Author: Logic <[email protected]>
AuthorDate: Tue Jun 9 23:47:29 2026 +0800

    Compact log attribute operator actions
---
 web-next/app/log/manage/log-manage-page.tsx | 336 +++++++++++-----------------
 web-next/app/log/manage/page.test.tsx       | 265 ++++++----------------
 web-next/lib/i18n-runtime-messages.ts       |  26 +++
 3 files changed, 221 insertions(+), 406 deletions(-)

diff --git a/web-next/app/log/manage/log-manage-page.tsx 
b/web-next/app/log/manage/log-manage-page.tsx
index 2ee01d09ef..f366fd07b2 100644
--- a/web-next/app/log/manage/log-manage-page.tsx
+++ b/web-next/app/log/manage/log-manage-page.tsx
@@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef, 
useState } from 'react'
 import Link from 'next/link';
 import {
   BarChart3,
-  Ban,
   BellPlus,
   BellRing,
   Check,
@@ -142,6 +141,7 @@ type LogSavedQueryView = SignalSavedQueryView;
 
 type LogExportRowLimit = 'current' | '10000' | '30000' | '50000';
 type LogDashboardPanelDraftState = 'idle' | 'saving' | 'saved' | 'failed';
+type LogAttributeOperator = 'context' | 'filter' | 'replace' | 'exclude' | 
'contains' | 'not-contains' | 'in' | 'not-in' | 'exists' | 'not-exists' | 
'group';
 
 type LogDetailContextPayload = {
   targetTimeUnixNano: number;
@@ -240,6 +240,19 @@ const LOG_SAVED_QUERY_VIEW_PERSISTENCE_OWNER: 
Record<SignalSavedQueryViewPersist
   'local-fallback': 'browser-local-storage'
 };
 const LOG_EXPORT_ROW_LIMITS: LogExportRowLimit[] = ['current', '10000', 
'30000', '50000'];
+const LOG_ATTRIBUTE_OPERATOR_LABEL_KEYS: Record<LogAttributeOperator, string> 
= {
+  context: 'log.manage.attributes.operator.context',
+  filter: 'log.manage.attributes.operator.filter',
+  replace: 'log.manage.attributes.operator.replace',
+  exclude: 'log.manage.attributes.operator.exclude',
+  contains: 'log.manage.attributes.operator.contains',
+  'not-contains': 'log.manage.attributes.operator.not-contains',
+  in: 'log.manage.attributes.operator.in',
+  'not-in': 'log.manage.attributes.operator.not-in',
+  exists: 'log.manage.attributes.operator.exists',
+  'not-exists': 'log.manage.attributes.operator.not-exists',
+  group: 'log.manage.attributes.operator.group'
+};
 const LOG_EXPORT_FETCH_PAGE_SIZE = 1000;
 const LOG_CONTEXT_WINDOW_MS = 5 * 60 * 1000;
 const LOG_CONTEXT_LIST_PAGE_SIZE = '20';
@@ -1212,6 +1225,46 @@ type RelatedTracePreviewState = {
 
 type LogManageTranslate = ReturnType<typeof useI18n>['t'];
 
+function buildLogAttributeOperatorOptions(t: LogManageTranslate, operators: 
LogAttributeOperator[]) {
+  return operators.map(operator => ({
+    value: operator,
+    label: t(LOG_ATTRIBUTE_OPERATOR_LABEL_KEYS[operator] as 
Parameters<LogManageTranslate>[0])
+  }));
+}
+
+function logAttributeOperatorDataAttributes(operator: LogAttributeOperator, 
kind: 'resource' | 'attribute', row: LogAttributeRow) {
+  const base = {
+    'data-log-manage-attribute-operator-option': operator,
+    'data-log-manage-attribute-operator-kind': kind,
+    'data-log-manage-attribute-filter-name': row.name,
+    'data-log-manage-attribute-filter-value': row.value
+  };
+  switch (operator) {
+    case 'context':
+      return { ...base, 'data-log-stream-detail-context-filter-action': kind, 
'data-log-stream-detail-context-filter-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'filter':
+      return { ...base, 'data-log-manage-attribute-filter-action': kind, 
'data-log-manage-attribute-filter-owner': 'hertzbeat-ui-select-menu-option' };
+    case 'replace':
+      return { ...base, 'data-log-manage-attribute-filter-replace-action': 
kind, 'data-log-manage-attribute-filter-replace-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'exclude':
+      return { ...base, 'data-log-manage-attribute-filter-out-action': kind, 
'data-log-manage-attribute-filter-out-owner': 'hertzbeat-ui-select-menu-option' 
};
+    case 'contains':
+      return { ...base, 'data-log-manage-attribute-contains-action': kind, 
'data-log-manage-attribute-contains-owner': 'hertzbeat-ui-select-menu-option' };
+    case 'not-contains':
+      return { ...base, 'data-log-manage-attribute-not-contains-action': kind, 
'data-log-manage-attribute-not-contains-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'in':
+      return { ...base, 'data-log-manage-attribute-in-action': kind, 
'data-log-manage-attribute-in-owner': 'hertzbeat-ui-select-menu-option' };
+    case 'not-in':
+      return { ...base, 'data-log-manage-attribute-not-in-action': kind, 
'data-log-manage-attribute-not-in-owner': 'hertzbeat-ui-select-menu-option' };
+    case 'exists':
+      return { ...base, 'data-log-manage-attribute-exists-action': kind, 
'data-log-manage-attribute-exists-owner': 'hertzbeat-ui-select-menu-option' };
+    case 'not-exists':
+      return { ...base, 'data-log-manage-attribute-not-exists-action': kind, 
'data-log-manage-attribute-not-exists-owner': 'hertzbeat-ui-select-menu-option' 
};
+    case 'group':
+      return { ...base, 'data-log-manage-attribute-group-action': kind, 
'data-log-manage-attribute-group-owner': 'hertzbeat-ui-select-menu-option' };
+  }
+}
+
 function isLogSavedQueryView(value: unknown): value is LogSavedQueryView {
   if (!value || typeof value !== 'object') return false;
   const candidate = value as Partial<LogSavedQueryView>;
@@ -2387,6 +2440,56 @@ function LogManageExplorer({
     applyQuery(nextQuery);
   }, [applyQuery, draft, setDraft]);
 
+  const applyLogAttributeOperator = useCallback((operator: 
LogAttributeOperator, row: LogAttributeRow) => {
+    switch (operator) {
+      case 'context':
+        applyLogContextAttributeFilter(row);
+        break;
+      case 'filter':
+        applyLogAttributeFilter(row);
+        break;
+      case 'replace':
+        replaceLogAttributeFilter(row);
+        break;
+      case 'exclude':
+        excludeLogAttributeFilter(row);
+        break;
+      case 'contains':
+        applyLogAttributeContainsFilter(row);
+        break;
+      case 'not-contains':
+        applyLogAttributeNotContainsFilter(row);
+        break;
+      case 'in':
+        applyLogAttributeInFilter(row);
+        break;
+      case 'not-in':
+        applyLogAttributeNotInFilter(row);
+        break;
+      case 'exists':
+        applyLogAttributeExistsFilter(row);
+        break;
+      case 'not-exists':
+        applyLogAttributeNotExistsFilter(row);
+        break;
+      case 'group':
+        groupLogAttribute(row);
+        break;
+    }
+  }, [
+    applyLogAttributeContainsFilter,
+    applyLogAttributeExistsFilter,
+    applyLogAttributeFilter,
+    applyLogAttributeInFilter,
+    applyLogAttributeNotContainsFilter,
+    applyLogAttributeNotExistsFilter,
+    applyLogAttributeNotInFilter,
+    applyLogContextAttributeFilter,
+    excludeLogAttributeFilter,
+    groupLogAttribute,
+    replaceLogAttributeFilter
+  ]);
+
   const applyLogAttributeFieldColumn = useCallback((row: LogAttributeRow) => {
     const fieldColumn = buildLogAttributeFieldColumn(row);
     if (!fieldColumn) return;
@@ -2450,6 +2553,7 @@ function LogManageExplorer({
   }, [activeGroupBy, applyQuery, applyRouteContext, draft, routeContext, 
setDraft]);
 
   const renderLogAttributeFilterAction = useCallback((row: LogAttributeRow) => 
{
+    const kind = resolveLogAttributeFilterKind(row);
     const filter = buildLogAttributeFilterExpression(row, 
t('log.manage.attributes.value.object'));
     const excludeFilter = buildLogAttributeExcludeExpression(row, 
t('log.manage.attributes.value.object'));
     const containsFilter = buildLogAttributeContainsExpression(row, 
t('log.manage.attributes.value.object'));
@@ -2461,7 +2565,18 @@ function LogManageExplorer({
     const group = buildLogAttributeGroupBy(row);
     const fieldColumn = buildLogAttributeFieldColumn(row);
     const fieldColumnVisible = Boolean(fieldColumn && 
visibleLogFieldColumns.includes(fieldColumn));
-    if (!filter && !excludeFilter && !containsFilter && !notContainsFilter && 
!inFilter && !notInFilter && !existsFilter && !notExistsFilter && !group && 
!fieldColumn) return null;
+    const availableOperators: LogAttributeOperator[] = [
+      ...(filter ? ['context', 'filter', 'replace'] as const : []),
+      ...(excludeFilter ? ['exclude'] as const : []),
+      ...(containsFilter ? ['contains'] as const : []),
+      ...(notContainsFilter ? ['not-contains'] as const : []),
+      ...(inFilter ? ['in'] as const : []),
+      ...(notInFilter ? ['not-in'] as const : []),
+      ...(existsFilter ? ['exists'] as const : []),
+      ...(notExistsFilter ? ['not-exists'] as const : []),
+      ...(group ? ['group'] as const : [])
+    ];
+    if (!availableOperators.length && !fieldColumn) return null;
     return (
       <span className="inline-flex flex-wrap gap-1">
         {fieldColumn ? (
@@ -2486,216 +2601,25 @@ function LogManageExplorer({
             {fieldColumnVisible ? 
t('log.manage.attributes.remove-column-action') : 
t('log.manage.attributes.add-column-action')}
           </HzButton>
         ) : null}
-        {filter ? (
-          <>
-            <HzButton
-              data-log-stream-detail-context-filter-action={filter.kind}
-              data-log-stream-detail-context-filter-owner="hertzbeat-ui-button"
-              data-log-manage-attribute-filter-name={row.name}
-              data-log-manage-attribute-filter-value={row.value}
-              size="sm"
-              intent="secondary"
-              onClick={() => applyLogContextAttributeFilter(row)}
-              
aria-label={t('log.manage.attributes.context-filter-action.aria', { name: 
row.name, value: row.value })}
-            >
-              <HzButtonIcon
-                icon={ScrollText}
-                data-log-stream-detail-context-filter-icon="context-filter"
-                
data-log-stream-detail-context-filter-icon-owner="hertzbeat-ui-button-icon"
-              />
-              {t('log.manage.attributes.context-filter-action')}
-            </HzButton>
-            <HzButton
-              data-log-manage-attribute-filter-action={filter.kind}
-              data-log-manage-attribute-filter-owner="hertzbeat-ui-button"
-              data-log-manage-attribute-filter-name={row.name}
-              data-log-manage-attribute-filter-value={row.value}
-              size="sm"
-              intent="secondary"
-              onClick={() => applyLogAttributeFilter(row)}
-              aria-label={t('log.manage.attributes.filter-action.aria', { 
name: row.name, value: row.value })}
-            >
-              <HzButtonIcon
-                icon={Filter}
-                data-log-manage-attribute-filter-action-icon="filter"
-                
data-log-manage-attribute-filter-action-icon-owner="hertzbeat-ui-button-icon"
-              />
-              {t('log.manage.attributes.filter-action')}
-            </HzButton>
-            <HzButton
-              data-log-manage-attribute-filter-replace-action={filter.kind}
-              
data-log-manage-attribute-filter-replace-owner="hertzbeat-ui-button"
-              data-log-manage-attribute-filter-name={row.name}
-              data-log-manage-attribute-filter-value={row.value}
-              size="sm"
-              intent="secondary"
-              onClick={() => replaceLogAttributeFilter(row)}
-              aria-label={t('log.manage.attributes.replace-action.aria', { 
name: row.name, value: row.value })}
-            >
-              <HzButtonIcon
-                icon={Replace}
-                data-log-manage-attribute-filter-replace-icon="replace"
-                
data-log-manage-attribute-filter-replace-icon-owner="hertzbeat-ui-button-icon"
-              />
-              {t('log.manage.attributes.replace-action')}
-            </HzButton>
-          </>
-        ) : null}
-        {excludeFilter ? (
-          <HzButton
-            data-log-manage-attribute-filter-out-action={excludeFilter.kind}
-            data-log-manage-attribute-filter-out-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => excludeLogAttributeFilter(row)}
-            aria-label={t('log.manage.attributes.filter-out-action.aria', { 
name: row.name, value: row.value })}
-          >
-            <HzButtonIcon
-              icon={X}
-              data-log-manage-attribute-filter-out-icon="exclude"
-              
data-log-manage-attribute-filter-out-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.filter-out-action')}
-          </HzButton>
-        ) : null}
-        {containsFilter ? (
-          <HzButton
-            data-log-manage-attribute-contains-action={containsFilter.kind}
-            data-log-manage-attribute-contains-owner="hertzbeat-ui-button"
+        {kind && availableOperators.length ? (
+          <HzSelect
+            data-log-manage-attribute-operator-action={kind}
+            data-log-manage-attribute-operator-owner="hertzbeat-ui-select"
             data-log-manage-attribute-filter-name={row.name}
             data-log-manage-attribute-filter-value={row.value}
             size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeContainsFilter(row)}
-            aria-label={t('log.manage.attributes.contains-action.aria', { 
name: row.name, value: row.value })}
-          >
-            <HzButtonIcon
-              icon={Search}
-              data-log-manage-attribute-contains-icon="contains"
-              
data-log-manage-attribute-contains-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.contains-action')}
-          </HzButton>
-        ) : null}
-        {notContainsFilter ? (
-          <HzButton
-            
data-log-manage-attribute-not-contains-action={notContainsFilter.kind}
-            data-log-manage-attribute-not-contains-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeNotContainsFilter(row)}
-            aria-label={t('log.manage.attributes.not-contains-action.aria', { 
name: row.name, value: row.value })}
-          >
-            <HzButtonIcon
-              icon={Ban}
-              data-log-manage-attribute-not-contains-icon="not-contains"
-              
data-log-manage-attribute-not-contains-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.not-contains-action')}
-          </HzButton>
-        ) : null}
-        {inFilter ? (
-          <HzButton
-            data-log-manage-attribute-in-action={inFilter.kind}
-            data-log-manage-attribute-in-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeInFilter(row)}
-            aria-label={t('log.manage.attributes.in-action.aria', { name: 
row.name, value: row.value })}
-          >
-            <HzButtonIcon
-              icon={ListChecks}
-              data-log-manage-attribute-in-icon="in"
-              
data-log-manage-attribute-in-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.in-action')}
-          </HzButton>
-        ) : null}
-        {notInFilter ? (
-          <HzButton
-            data-log-manage-attribute-not-in-action={notInFilter.kind}
-            data-log-manage-attribute-not-in-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeNotInFilter(row)}
-            aria-label={t('log.manage.attributes.not-in-action.aria', { name: 
row.name, value: row.value })}
-          >
-            <HzButtonIcon
-              icon={Ban}
-              data-log-manage-attribute-not-in-icon="not-in"
-              
data-log-manage-attribute-not-in-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.not-in-action')}
-          </HzButton>
-        ) : null}
-        {existsFilter ? (
-          <HzButton
-            data-log-manage-attribute-exists-action={existsFilter.kind}
-            data-log-manage-attribute-exists-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeExistsFilter(row)}
-            aria-label={t('log.manage.attributes.exists-action.aria', { name: 
row.name })}
-          >
-            <HzButtonIcon
-              icon={Check}
-              data-log-manage-attribute-exists-icon="exists"
-              
data-log-manage-attribute-exists-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.exists-action')}
-          </HzButton>
-        ) : null}
-        {notExistsFilter ? (
-          <HzButton
-            data-log-manage-attribute-not-exists-action={notExistsFilter.kind}
-            data-log-manage-attribute-not-exists-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => applyLogAttributeNotExistsFilter(row)}
-            aria-label={t('log.manage.attributes.not-exists-action.aria', { 
name: row.name })}
-          >
-            <HzButtonIcon
-              icon={Ban}
-              data-log-manage-attribute-not-exists-icon="not-exists"
-              
data-log-manage-attribute-not-exists-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.not-exists-action')}
-          </HzButton>
-        ) : null}
-        {group ? (
-          <HzButton
-            data-log-manage-attribute-group-action={group.kind}
-            data-log-manage-attribute-group-owner="hertzbeat-ui-button"
-            data-log-manage-attribute-filter-name={row.name}
-            data-log-manage-attribute-filter-value={row.value}
-            size="sm"
-            intent="secondary"
-            onClick={() => groupLogAttribute(row)}
-            aria-label={t('log.manage.attributes.group-action.aria', { name: 
row.name })}
-          >
-            <HzButtonIcon
-              icon={BarChart3}
-              data-log-manage-attribute-group-icon="group"
-              
data-log-manage-attribute-group-icon-owner="hertzbeat-ui-button-icon"
-            />
-            {t('log.manage.attributes.group-action')}
-          </HzButton>
+            width="log-severity"
+            value=""
+            placeholder={t('log.manage.attributes.operator.placeholder')}
+            aria-label={t('log.manage.attributes.operator-action.aria', { 
name: row.name, value: row.value })}
+            options={buildLogAttributeOperatorOptions(t, availableOperators)}
+            optionDataAttributes={option => 
logAttributeOperatorDataAttributes(option.value as LogAttributeOperator, kind, 
row)}
+            onChange={event => applyLogAttributeOperator(event.target.value as 
LogAttributeOperator, row)}
+          />
         ) : null}
       </span>
     );
-  }, [applyLogAttributeContainsFilter, applyLogAttributeExistsFilter, 
applyLogAttributeFieldColumn, applyLogAttributeFilter, 
applyLogAttributeInFilter, applyLogAttributeNotContainsFilter, 
applyLogAttributeNotExistsFilter, applyLogAttributeNotInFilter, 
applyLogContextAttributeFilter, excludeLogAttributeFilter, groupLogAttribute, 
replaceLogAttributeFilter, t, visibleLogFieldColumns]);
+  }, [applyLogAttributeFieldColumn, applyLogAttributeOperator, t, 
visibleLogFieldColumns]);
 
   const openLogDetails = (entry: LogEntry | null, source: 'history' | 
'stream', selectionState: 'attached' | 'detached' = 'attached') => {
     if (!entry) return;
diff --git a/web-next/app/log/manage/page.test.tsx 
b/web-next/app/log/manage/page.test.tsx
index 3fcac0abca..c7f07d40d0 100644
--- a/web-next/app/log/manage/page.test.tsx
+++ b/web-next/app/log/manage/page.test.tsx
@@ -581,6 +581,31 @@ function renderInteractiveLogManagePage(initialRouteState 
= buildLogManageRouteS
   interactionRoot?.render(<LogManagePage initialRouteState={initialRouteState} 
/>);
 }
 
+async function applyLogAttributeOperator(kind: 'resource' | 'attribute', name: 
string, operator: string) {
+  const operatorRoot = interactionContainer?.querySelector(
+    
`[data-log-manage-attribute-operator-action="${kind}"][data-log-manage-attribute-filter-name="${name}"]`
+  ) as HTMLElement | null;
+  expect(operatorRoot).toBeTruthy();
+  
expect(operatorRoot?.getAttribute('data-log-manage-attribute-operator-owner')).toBe('hertzbeat-ui-select');
+  expect(operatorRoot?.getAttribute('data-hz-ui')).toBe('select');
+
+  await act(async () => {
+    (operatorRoot?.querySelector('[data-hz-ui="select-trigger"]') as 
HTMLButtonElement | null)?.click();
+    await Promise.resolve();
+  });
+
+  const option = operatorRoot?.querySelector(
+    
`[data-log-manage-attribute-operator-option="${operator}"][data-log-manage-attribute-operator-kind="${kind}"][data-log-manage-attribute-filter-name="${name}"]`
+  ) as HTMLButtonElement | null;
+  expect(option).toBeTruthy();
+
+  await act(async () => {
+    option?.click();
+    await Promise.resolve();
+  });
+  return String(mockState.replace.mock.calls.at(-1)?.[0]);
+}
+
 async function flushDashboardEditPromises() {
   for (let index = 0; index < 8; index += 1) {
     await Promise.resolve();
@@ -655,20 +680,22 @@ describe('log manage page', () => {
     expect(source).toContain('applyLogAttributeExistsFilter');
     expect(source).toContain('buildLogAttributeContainsExpression');
     expect(source).toContain('applyLogAttributeContainsFilter');
-    
expect(source).toContain('data-log-manage-attribute-contains-action={containsFilter.kind}');
     expect(source).toContain('buildLogAttributeNotContainsExpression');
     expect(source).toContain('applyLogAttributeNotContainsFilter');
-    
expect(source).toContain('data-log-manage-attribute-not-contains-action={notContainsFilter.kind}');
     expect(source).toContain('buildLogAttributeInExpression');
     expect(source).toContain('applyLogAttributeInFilter');
-    
expect(source).toContain('data-log-manage-attribute-in-action={inFilter.kind}');
     expect(source).toContain('buildLogAttributeNotInExpression');
     expect(source).toContain('applyLogAttributeNotInFilter');
-    
expect(source).toContain('data-log-manage-attribute-not-in-action={notInFilter.kind}');
-    
expect(source).toContain('data-log-manage-attribute-exists-action={existsFilter.kind}');
-    
expect(source).toContain('data-log-manage-attribute-not-exists-action={notExistsFilter.kind}');
     expect(source).toContain('buildLogAttributeNotExistsExpression');
-    
expect(source).toContain('data-log-manage-attribute-exists-owner="hertzbeat-ui-button"');
+    expect(source).toContain('buildLogAttributeOperatorOptions');
+    expect(source).toContain('logAttributeOperatorDataAttributes');
+    expect(source).toContain('applyLogAttributeOperator');
+    
expect(source).toContain('data-log-manage-attribute-operator-action={kind}');
+    
expect(source).toContain('data-log-manage-attribute-operator-owner="hertzbeat-ui-select"');
+    expect(source).toContain('optionDataAttributes={option => 
logAttributeOperatorDataAttributes(option.value as LogAttributeOperator, kind, 
row)}');
+    
expect(source).not.toContain('data-log-manage-attribute-exists-owner="hertzbeat-ui-button"');
+    
expect(source).not.toContain('data-log-manage-attribute-contains-owner="hertzbeat-ui-button"');
+    
expect(source).not.toContain('data-log-manage-attribute-group-owner="hertzbeat-ui-button"');
     
expect(source).toContain('data-log-manage-stream-selected-detail-owner="hertzbeat-ui-detail-rows"');
     
expect(source).toContain('data-log-manage-stream-detail-action-stack="shared-control-stack"');
     
expect(source).toContain('data-log-manage-stream-detail-action-stack-owner="hertzbeat-ui-control-stack"');
@@ -3259,16 +3286,10 @@ describe('log manage page', () => {
       await Promise.resolve();
     });
 
-    const contextFilterAction = interactionContainer.querySelector(
-      
'[data-log-stream-detail-context-filter-action="attribute"][data-log-manage-attribute-filter-name="region"]'
-    ) as HTMLButtonElement | null;
-    expect(contextFilterAction).toBeTruthy();
-    
expect(contextFilterAction?.getAttribute('data-log-stream-detail-context-filter-owner')).toBe('hertzbeat-ui-button');
     mockState.replace.mockClear();
 
+    await applyLogAttributeOperator('attribute', 'region', 'context');
     await act(async () => {
-      contextFilterAction?.click();
-      await Promise.resolve();
       await Promise.resolve();
     });
 
@@ -3377,31 +3398,25 @@ describe('log manage page', () => {
         await Promise.resolve();
       });
 
-      const resourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const attributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(resourceAction).toBeTruthy();
-      expect(attributeAction).toBeTruthy();
-      
expect(resourceAction?.getAttribute('data-log-manage-attribute-filter-owner')).toBe('hertzbeat-ui-button');
-      
expect(attributeAction?.getAttribute('data-log-manage-attribute-filter-owner')).toBe('hertzbeat-ui-button');
+      const resourceOperator = interactionContainer.querySelector(
+        
'[data-log-manage-attribute-operator-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
+      ) as HTMLElement | null;
+      const attributeOperator = interactionContainer.querySelector(
+        
'[data-log-manage-attribute-operator-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
+      ) as HTMLElement | null;
+      expect(resourceOperator).toBeTruthy();
+      expect(attributeOperator).toBeTruthy();
+      
expect(resourceOperator?.getAttribute('data-log-manage-attribute-operator-owner')).toBe('hertzbeat-ui-select');
+      
expect(attributeOperator?.getAttribute('data-log-manage-attribute-operator-owner')).toBe('hertzbeat-ui-select');
       mockState.replace.mockClear();
 
-      await act(async () => {
-        resourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 'filter');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       
expect(String(mockState.replace.mock.calls[0]?.[0])).toContain('resourceFilter=service.version%3D1.2.3');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        attributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'filter');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       
expect(String(mockState.replace.mock.calls[0]?.[0])).toContain('attributeFilter=http.route%3A%2Fcheckout%2F%3Aid');
@@ -3436,205 +3451,87 @@ describe('log manage page', () => {
         await Promise.resolve();
       });
 
-      const excludeResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-out-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const excludeAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-out-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(excludeResourceAction).toBeTruthy();
-      expect(excludeAttributeAction).toBeTruthy();
-      
expect(excludeResourceAction?.getAttribute('data-log-manage-attribute-filter-out-owner')).toBe('hertzbeat-ui-button');
-      
expect(excludeAttributeAction?.getAttribute('data-log-manage-attribute-filter-out-owner')).toBe('hertzbeat-ui-button');
       mockState.replace.mockClear();
 
-      await act(async () => {
-        excludeResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 
'exclude');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       
expect(String(mockState.replace.mock.calls[0]?.[0])).toContain('resourceFilter=service.version%21%3D1.2.3');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        excludeAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'exclude');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       
expect(String(mockState.replace.mock.calls[0]?.[0])).toContain('attributeFilter=http.route%21%3D%2Fcheckout%2F%3Aid');
 
-      const existsResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-exists-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const existsAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-exists-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(existsResourceAction).toBeTruthy();
-      expect(existsAttributeAction).toBeTruthy();
-      
expect(existsResourceAction?.getAttribute('data-log-manage-attribute-exists-owner')).toBe('hertzbeat-ui-button');
-      
expect(existsAttributeAction?.getAttribute('data-log-manage-attribute-exists-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        existsResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 'exists');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 EXISTS');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        existsAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'exists');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
EXISTS');
 
-      const notExistsResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-exists-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const notExistsAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-exists-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(notExistsResourceAction).toBeTruthy();
-      expect(notExistsAttributeAction).toBeTruthy();
-      
expect(notExistsResourceAction?.getAttribute('data-log-manage-attribute-not-exists-owner')).toBe('hertzbeat-ui-button');
-      
expect(notExistsAttributeAction?.getAttribute('data-log-manage-attribute-not-exists-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        notExistsResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 
'not-exists');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 NOT EXISTS');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        notExistsAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'not-exists');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
NOT EXISTS');
 
-      const containsResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-contains-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const containsAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-contains-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(containsResourceAction).toBeTruthy();
-      expect(containsAttributeAction).toBeTruthy();
-      
expect(containsResourceAction?.getAttribute('data-log-manage-attribute-contains-owner')).toBe('hertzbeat-ui-button');
-      
expect(containsAttributeAction?.getAttribute('data-log-manage-attribute-contains-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        containsResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 
'contains');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 CONTAINS 1.2.3');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        containsAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'contains');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
CONTAINS /checkout/:id');
 
-      const notContainsResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-contains-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const notContainsAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-contains-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(notContainsResourceAction).toBeTruthy();
-      expect(notContainsAttributeAction).toBeTruthy();
-      
expect(notContainsResourceAction?.getAttribute('data-log-manage-attribute-not-contains-owner')).toBe('hertzbeat-ui-button');
-      
expect(notContainsAttributeAction?.getAttribute('data-log-manage-attribute-not-contains-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        notContainsResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 
'not-contains');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 NOT CONTAINS 1.2.3');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        notContainsAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 
'not-contains');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
NOT CONTAINS /checkout/:id');
 
-      const inResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-in-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const inAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-in-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(inResourceAction).toBeTruthy();
-      expect(inAttributeAction).toBeTruthy();
-      
expect(inResourceAction?.getAttribute('data-log-manage-attribute-in-owner')).toBe('hertzbeat-ui-button');
-      
expect(inAttributeAction?.getAttribute('data-log-manage-attribute-in-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        inResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 'in');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 IN ("1.2.3")');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        inAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'in');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
IN ("/checkout/:id")');
 
-      const notInResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-in-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const notInAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-not-in-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(notInResourceAction).toBeTruthy();
-      expect(notInAttributeAction).toBeTruthy();
-      
expect(notInResourceAction?.getAttribute('data-log-manage-attribute-not-in-owner')).toBe('hertzbeat-ui-button');
-      
expect(notInAttributeAction?.getAttribute('data-log-manage-attribute-not-in-owner')).toBe('hertzbeat-ui-button');
-
       mockState.replace.mockClear();
-      await act(async () => {
-        notInResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 'not-in');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('resourceFilter')).toContain('service.version
 NOT IN ("1.2.3")');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        notInAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'not-in');
 
       expect(mockState.replace).toHaveBeenCalledTimes(1);
       expect(new URL(String(mockState.replace.mock.calls[0]?.[0]), 
'http://localhost').searchParams.get('attributeFilter')).toContain('http.route 
NOT IN ("/checkout/:id")');
@@ -3671,32 +3568,16 @@ describe('log manage page', () => {
         await Promise.resolve();
       });
 
-      const replaceResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-replace-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const replaceAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-filter-replace-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(replaceResourceAction).toBeTruthy();
-      expect(replaceAttributeAction).toBeTruthy();
-      
expect(replaceResourceAction?.getAttribute('data-log-manage-attribute-filter-replace-owner')).toBe('hertzbeat-ui-button');
-      
expect(replaceAttributeAction?.getAttribute('data-log-manage-attribute-filter-replace-owner')).toBe('hertzbeat-ui-button');
       mockState.replace.mockClear();
 
-      await act(async () => {
-        replaceResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 
'replace');
 
       const resourceRoute = String(mockState.replace.mock.calls[0]?.[0]);
       
expect(resourceRoute).toContain('resourceFilter=service.version%3D1.2.3');
       
expect(resourceRoute).not.toContain('resourceFilter=service.version%3D1.0.0');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        replaceAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'replace');
 
       const attributeRoute = String(mockState.replace.mock.calls[0]?.[0]);
       
expect(attributeRoute).toContain('attributeFilter=http.route%3A%2Fcheckout%2F%3Aid');
@@ -3732,32 +3613,16 @@ describe('log manage page', () => {
         await Promise.resolve();
       });
 
-      const groupResourceAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-group-action="resource"][data-log-manage-attribute-filter-name="service.version"]'
-      ) as HTMLButtonElement | null;
-      const groupAttributeAction = interactionContainer.querySelector(
-        
'[data-log-manage-attribute-group-action="attribute"][data-log-manage-attribute-filter-name="http.route"]'
-      ) as HTMLButtonElement | null;
-      expect(groupResourceAction).toBeTruthy();
-      expect(groupAttributeAction).toBeTruthy();
-      
expect(groupResourceAction?.getAttribute('data-log-manage-attribute-group-owner')).toBe('hertzbeat-ui-button');
-      
expect(groupAttributeAction?.getAttribute('data-log-manage-attribute-group-owner')).toBe('hertzbeat-ui-button');
       mockState.replace.mockClear();
 
-      await act(async () => {
-        groupResourceAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('resource', 'service.version', 'group');
 
       const resourceRoute = String(mockState.replace.mock.calls[0]?.[0]);
       expect(resourceRoute).toContain('groupBy=resource%3Aservice.version');
       
expect(resourceRoute).not.toContain('groupBy=resource%3Aservice.namespace');
 
       mockState.replace.mockClear();
-      await act(async () => {
-        groupAttributeAction?.click();
-        await Promise.resolve();
-      });
+      await applyLogAttributeOperator('attribute', 'http.route', 'group');
 
       const attributeRoute = String(mockState.replace.mock.calls[0]?.[0]);
       expect(attributeRoute).toContain('groupBy=attribute%3Ahttp.route');
diff --git a/web-next/lib/i18n-runtime-messages.ts 
b/web-next/lib/i18n-runtime-messages.ts
index d617911679..3ac5d8cd11 100644
--- a/web-next/lib/i18n-runtime-messages.ts
+++ b/web-next/lib/i18n-runtime-messages.ts
@@ -4049,6 +4049,19 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'log.manage.attributes.replace-action.aria': 'Replace filters with 
{{name}} equals {{value}}',
     'log.manage.attributes.group-action': 'Group',
     'log.manage.attributes.group-action.aria': 'Group logs by {{name}}',
+    'log.manage.attributes.operator.placeholder': 'Choose',
+    'log.manage.attributes.operator-action.aria': 'Choose a log attribute 
operator for {{name}} {{value}}',
+    'log.manage.attributes.operator.context': 'Filter context',
+    'log.manage.attributes.operator.filter': 'Filter',
+    'log.manage.attributes.operator.replace': 'Replace',
+    'log.manage.attributes.operator.exclude': 'Exclude',
+    'log.manage.attributes.operator.contains': 'Contains',
+    'log.manage.attributes.operator.not-contains': 'Not contains',
+    'log.manage.attributes.operator.in': 'In',
+    'log.manage.attributes.operator.not-in': 'Not in',
+    'log.manage.attributes.operator.exists': 'Exists',
+    'log.manage.attributes.operator.not-exists': 'Not exists',
+    'log.manage.attributes.operator.group': 'Group',
     'log.manage.attributes.add-column-action': 'Add column',
     'log.manage.attributes.add-column-action.aria': 'Add {{name}} as a log 
table column',
     'log.manage.attributes.remove-column-action': 'Remove column',
@@ -8573,6 +8586,19 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'log.manage.attributes.replace-action.aria': '替换为 {{name}} 等于 {{value}} 
的过滤条件',
     'log.manage.attributes.group-action': '分组',
     'log.manage.attributes.group-action.aria': '按 {{name}} 分组日志',
+    'log.manage.attributes.operator.placeholder': '选择',
+    'log.manage.attributes.operator-action.aria': '为 {{name}} {{value}} 
选择日志属性操作',
+    'log.manage.attributes.operator.context': '筛选上下文',
+    'log.manage.attributes.operator.filter': '过滤',
+    'log.manage.attributes.operator.replace': '替换',
+    'log.manage.attributes.operator.exclude': '排除',
+    'log.manage.attributes.operator.contains': '包含',
+    'log.manage.attributes.operator.not-contains': '不包含',
+    'log.manage.attributes.operator.in': '属于',
+    'log.manage.attributes.operator.not-in': '不属于',
+    'log.manage.attributes.operator.exists': '存在',
+    'log.manage.attributes.operator.not-exists': '不存在',
+    'log.manage.attributes.operator.group': '分组',
     'log.manage.attributes.add-column-action': '添加列',
     'log.manage.attributes.add-column-action.aria': '将 {{name}} 添加为日志表格列',
     'log.manage.attributes.remove-column-action': '移除列',


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to