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 9d2aeddbb7 Compact metrics attribute operator actions
9d2aeddbb7 is described below

commit 9d2aeddbb75f1b7ac89aba24c4dcaf3e4cc88910
Author: Logic <[email protected]>
AuthorDate: Tue Jun 9 23:38:22 2026 +0800

    Compact metrics attribute operator actions
---
 .../ingestion/otlp/metrics/otlp-metrics-page.tsx   | 315 +++++++--------------
 web-next/app/ingestion/otlp/metrics/page.test.tsx  | 185 ++++--------
 web-next/lib/i18n-runtime-messages.ts              |  26 ++
 3 files changed, 187 insertions(+), 339 deletions(-)

diff --git a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx 
b/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
index 6f6f7e8f4f..74aca8e8a2 100644
--- a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
+++ b/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
@@ -62,6 +62,7 @@ type OtlpMetricsTranslate = ReturnType<typeof useI18n>['t'];
 
 type MetricsSavedQueryView = SignalSavedQueryView;
 type MetricsDashboardPanelDraftState = 'idle' | 'saving' | 'saved' | 'failed';
+type MetricAttributeOperator = 'filter' | 'contains' | 'not-contains' | 'in' | 
'not-in' | 'exclude' | 'exists' | 'not-exists' | 'replace' | 'group';
 
 const METRICS_SAVED_QUERY_VIEW_STORAGE_KEY = 
'hertzbeat.otlp-metrics.saved-query-views';
 const METRICS_SAVED_QUERY_VIEW_LIMIT = 5;
@@ -73,6 +74,43 @@ const DEFAULT_METRIC_INVENTORY_PAGE_SIZE = '10';
 const DEFAULT_METRIC_INVENTORY_PAGE_INDEX = '0';
 const METRIC_INVENTORY_PAGE_SIZE_OPTIONS = ['5', '10', '20', '50'] as const;
 const METRICS_EXPORT_SCOPES: OtlpMetricsExportScope[] = ['all', 'selected'];
+const METRIC_ATTRIBUTE_OPERATORS: MetricAttributeOperator[] = ['filter', 
'contains', 'not-contains', 'in', 'not-in', 'exclude', 'exists', 'not-exists', 
'replace', 'group'];
+
+function buildMetricAttributeOperatorOptions(t: OtlpMetricsTranslate) {
+  return METRIC_ATTRIBUTE_OPERATORS.map(operator => ({
+    value: operator,
+    label: t(`otlp.metrics.attributes.operator.${operator}` as const)
+  }));
+}
+
+function metricAttributeOperatorDataAttributes(operator: 
MetricAttributeOperator, name: string) {
+  const base = {
+    'data-otlp-metrics-attribute-operator-option': operator,
+    'data-otlp-metrics-attribute-operator-name': name
+  };
+  switch (operator) {
+    case 'filter':
+      return { ...base, 'data-otlp-metrics-attribute-filter-action': name, 
'data-otlp-metrics-attribute-filter-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'contains':
+      return { ...base, 'data-otlp-metrics-attribute-contains-action': name, 
'data-otlp-metrics-attribute-contains-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'not-contains':
+      return { ...base, 'data-otlp-metrics-attribute-not-contains-action': 
name, 'data-otlp-metrics-attribute-not-contains-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'in':
+      return { ...base, 'data-otlp-metrics-attribute-in-action': name, 
'data-otlp-metrics-attribute-in-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'not-in':
+      return { ...base, 'data-otlp-metrics-attribute-not-in-action': name, 
'data-otlp-metrics-attribute-not-in-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'exclude':
+      return { ...base, 'data-otlp-metrics-attribute-filter-out-action': name, 
'data-otlp-metrics-attribute-filter-out-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'exists':
+      return { ...base, 'data-otlp-metrics-attribute-exists-action': name, 
'data-otlp-metrics-attribute-exists-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'not-exists':
+      return { ...base, 'data-otlp-metrics-attribute-not-exists-action': name, 
'data-otlp-metrics-attribute-not-exists-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'replace':
+      return { ...base, 'data-otlp-metrics-attribute-replace-action': name, 
'data-otlp-metrics-attribute-replace-action-owner': 
'hertzbeat-ui-select-menu-option' };
+    case 'group':
+      return { ...base, 'data-otlp-metrics-attribute-group-action': name, 
'data-otlp-metrics-attribute-group-action-owner': 
'hertzbeat-ui-select-menu-option' };
+  }
+}
 
 function resolveMetricInventoryPageSize(value?: string) {
   return METRIC_INVENTORY_PAGE_SIZE_OPTIONS.find(option => option === value) 
|| DEFAULT_METRIC_INVENTORY_PAGE_SIZE;
@@ -969,6 +1007,52 @@ export default function OtlpMetricsPage() {
     replaceMetricsRoute(nextDraft, undefined, series?.key || query.series, 
series ? buildMetricSeriesRouteContext(series) : {});
   }, [draft, query.series, replaceMetricsRoute]);
 
+  const applyMetricAttributeOperator = useCallback((operator: 
MetricAttributeOperator, name: string, value: string, series?: 
OtlpMetricSeriesView | null) => {
+    switch (operator) {
+      case 'filter':
+        applyMetricAttributeFilter(name, value, series);
+        break;
+      case 'contains':
+        applyMetricAttributeContainsFilter(name, value, series);
+        break;
+      case 'not-contains':
+        applyMetricAttributeNotContainsFilter(name, value, series);
+        break;
+      case 'in':
+        applyMetricAttributeInFilter(name, value, series);
+        break;
+      case 'not-in':
+        applyMetricAttributeNotInFilter(name, value, series);
+        break;
+      case 'exclude':
+        applyMetricAttributeExcludeFilter(name, value, series);
+        break;
+      case 'exists':
+        applyMetricAttributeExistsFilter(name, series);
+        break;
+      case 'not-exists':
+        applyMetricAttributeNotExistsFilter(name, series);
+        break;
+      case 'replace':
+        applyMetricAttributeReplaceFilter(name, value, series);
+        break;
+      case 'group':
+        applyMetricAttributeGroupBy(name, series);
+        break;
+    }
+  }, [
+    applyMetricAttributeContainsFilter,
+    applyMetricAttributeExcludeFilter,
+    applyMetricAttributeExistsFilter,
+    applyMetricAttributeFilter,
+    applyMetricAttributeGroupBy,
+    applyMetricAttributeInFilter,
+    applyMetricAttributeNotContainsFilter,
+    applyMetricAttributeNotExistsFilter,
+    applyMetricAttributeNotInFilter,
+    applyMetricAttributeReplaceFilter
+  ]);
+
   const applyMetricsTimeContext = useCallback((timeContext: TimeContext) => {
     const sanitized = sanitizeTimeContext(timeContext);
     setDraft(previous => ({
@@ -1039,6 +1123,7 @@ export default function OtlpMetricsPage() {
         const selectedSeriesEvidenceRows = 
buildMetricSeriesEvidenceRows(selectedMetricSeries, formatTime, t);
         const selectedSeriesSampleRows = 
buildMetricSeriesSampleRows(selectedMetricSeries, formatTime, t);
         const selectedSeriesAttributeRows = 
buildMetricSeriesAttributeRows(selectedMetricSeries, metricAttributeSearch);
+        const metricAttributeOperatorOptions = 
buildMetricAttributeOperatorOptions(t);
         const selectedSeriesContextDetailRows = 
selectedSeriesContextRows.map(row => ({
           key: row.label,
           title: row.label,
@@ -2688,223 +2773,21 @@ export default function OtlpMetricsPage() {
                             render: row => <HzDataCellText variant="meta" 
display="block" casing="plain">{row.value}</HzDataCellText>
                           },
                           {
-                            key: 'filter',
-                            header: t('otlp.metrics.attributes.column.filter'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-filter-action={row.name}
-                                
data-otlp-metrics-attribute-filter-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.filter-action.aria', { name: row.name, 
value: row.value })}
-                                onClick={() => 
applyMetricAttributeFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Filter}
-                                  
data-otlp-metrics-attribute-filter-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-filter-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.filter-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'contains',
-                            header: 
t('otlp.metrics.attributes.column.contains'),
+                            key: 'operator',
+                            header: 
t('otlp.metrics.attributes.column.operator'),
                             render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-contains-action={row.name}
-                                
data-otlp-metrics-attribute-contains-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.contains-action.aria', { name: row.name, 
value: row.value })}
-                                onClick={() => 
applyMetricAttributeContainsFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Search}
-                                  
data-otlp-metrics-attribute-contains-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-contains-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.contains-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'not-contains',
-                            header: 
t('otlp.metrics.attributes.column.not-contains'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-not-contains-action={row.name}
-                                
data-otlp-metrics-attribute-not-contains-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.not-contains-action.aria', { name: 
row.name, value: row.value })}
-                                onClick={() => 
applyMetricAttributeNotContainsFilter(row.name, row.value, 
selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Ban}
-                                  
data-otlp-metrics-attribute-not-contains-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-not-contains-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                
{t('otlp.metrics.attributes.not-contains-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'in',
-                            header: t('otlp.metrics.attributes.column.in'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-in-action={row.name}
-                                
data-otlp-metrics-attribute-in-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.in-action.aria', { name: row.name, 
value: row.value })}
-                                onClick={() => 
applyMetricAttributeInFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={ListChecks}
-                                  
data-otlp-metrics-attribute-in-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-in-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.in-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'not-in',
-                            header: t('otlp.metrics.attributes.column.not-in'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-not-in-action={row.name}
-                                
data-otlp-metrics-attribute-not-in-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.not-in-action.aria', { name: row.name, 
value: row.value })}
-                                onClick={() => 
applyMetricAttributeNotInFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Ban}
-                                  
data-otlp-metrics-attribute-not-in-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-not-in-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.not-in-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'exclude',
-                            header: 
t('otlp.metrics.attributes.column.exclude'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-filter-out-action={row.name}
-                                
data-otlp-metrics-attribute-filter-out-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.filter-out-action.aria', { name: 
row.name, value: row.value })}
-                                onClick={() => 
applyMetricAttributeExcludeFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={X}
-                                  
data-otlp-metrics-attribute-filter-out-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-filter-out-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                
{t('otlp.metrics.attributes.filter-out-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'exists',
-                            header: t('otlp.metrics.attributes.column.exists'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-exists-action={row.name}
-                                
data-otlp-metrics-attribute-exists-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.exists-action.aria', { name: row.name })}
-                                onClick={() => 
applyMetricAttributeExistsFilter(row.name, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Check}
-                                  
data-otlp-metrics-attribute-exists-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-exists-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.exists-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'not-exists',
-                            header: 
t('otlp.metrics.attributes.column.not-exists'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-not-exists-action={row.name}
-                                
data-otlp-metrics-attribute-not-exists-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.not-exists-action.aria', { name: 
row.name })}
-                                onClick={() => 
applyMetricAttributeNotExistsFilter(row.name, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Ban}
-                                  
data-otlp-metrics-attribute-not-exists-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-not-exists-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                
{t('otlp.metrics.attributes.not-exists-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'replace',
-                            header: 
t('otlp.metrics.attributes.column.replace'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-replace-action={row.name}
-                                
data-otlp-metrics-attribute-replace-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.replace-action.aria', { name: row.name, 
value: row.value })}
-                                onClick={() => 
applyMetricAttributeReplaceFilter(row.name, row.value, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Replace}
-                                  
data-otlp-metrics-attribute-replace-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-replace-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.replace-action')}
-                              </HzButton>
-                            )
-                          },
-                          {
-                            key: 'group',
-                            header: t('otlp.metrics.attributes.column.group'),
-                            render: row => (
-                              <HzButton
-                                type="button"
-                                size="xs"
-                                intent="ghost"
-                                
data-otlp-metrics-attribute-group-action={row.name}
-                                
data-otlp-metrics-attribute-group-action-owner="hertzbeat-ui-button"
-                                
aria-label={t('otlp.metrics.attributes.group-action.aria', { name: row.name })}
-                                onClick={() => 
applyMetricAttributeGroupBy(row.name, selectedMetricSeries)}
-                              >
-                                <HzButtonIcon
-                                  icon={Workflow}
-                                  
data-otlp-metrics-attribute-group-action-icon={row.name}
-                                  
data-otlp-metrics-attribute-group-action-icon-owner="hertzbeat-ui-button-icon"
-                                />
-                                {t('otlp.metrics.attributes.group-action')}
-                              </HzButton>
+                              <HzSelect
+                                
data-otlp-metrics-attribute-operator-action={row.name}
+                                
data-otlp-metrics-attribute-operator-action-owner="hertzbeat-ui-select"
+                                width="metrics-group-by"
+                                size="sm"
+                                value=""
+                                
placeholder={t('otlp.metrics.attributes.operator.placeholder')}
+                                
aria-label={t('otlp.metrics.attributes.operator-action.aria', { name: row.name, 
value: row.value })}
+                                options={metricAttributeOperatorOptions}
+                                optionDataAttributes={option => 
metricAttributeOperatorDataAttributes(option.value as MetricAttributeOperator, 
row.name)}
+                                onChange={event => 
applyMetricAttributeOperator(event.target.value as MetricAttributeOperator, 
row.name, row.value, selectedMetricSeries)}
+                              />
                             )
                           }
                         ]}
diff --git a/web-next/app/ingestion/otlp/metrics/page.test.tsx 
b/web-next/app/ingestion/otlp/metrics/page.test.tsx
index 7364946b7e..a60c5a5c7b 100644
--- a/web-next/app/ingestion/otlp/metrics/page.test.tsx
+++ b/web-next/app/ingestion/otlp/metrics/page.test.tsx
@@ -1037,13 +1037,7 @@ describe('otlp metrics page', () => {
     
expect(source).toContain('data-otlp-metrics-attribute-table-owner="hertzbeat-ui-data-table"');
     expect(source).toContain("header: 
t('otlp.metrics.attributes.column.name')");
     expect(source).toContain("header: 
t('otlp.metrics.attributes.column.value')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.contains')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.not-contains')");
-    expect(source).toContain("header: t('otlp.metrics.attributes.column.in')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.not-in')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.exclude')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.exists')");
-    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.not-exists')");
+    expect(source).toContain("header: 
t('otlp.metrics.attributes.column.operator')");
     expect(source).toContain('buildMetricAttributeExcludeFilterExpression');
     expect(source).toContain('buildMetricAttributeContainsFilterExpression');
     
expect(source).toContain('buildMetricAttributeNotContainsFilterExpression');
@@ -1058,20 +1052,15 @@ describe('otlp metrics page', () => {
     expect(source).toContain('applyMetricAttributeNotInFilter');
     expect(source).toContain('applyMetricAttributeExistsFilter');
     expect(source).toContain('applyMetricAttributeNotExistsFilter');
-    
expect(source).toContain('data-otlp-metrics-attribute-filter-out-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-filter-out-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-contains-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-contains-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-contains-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-contains-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-in-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-in-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-in-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-in-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-exists-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-exists-action-owner="hertzbeat-ui-button"');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-exists-action={row.name}');
-    
expect(source).toContain('data-otlp-metrics-attribute-not-exists-action-owner="hertzbeat-ui-button"');
+    expect(source).toContain('buildMetricAttributeOperatorOptions');
+    expect(source).toContain('metricAttributeOperatorDataAttributes');
+    expect(source).toContain('applyMetricAttributeOperator');
+    
expect(source).toContain('data-otlp-metrics-attribute-operator-action={row.name}');
+    
expect(source).toContain('data-otlp-metrics-attribute-operator-action-owner="hertzbeat-ui-select"');
+    expect(source).toContain('optionDataAttributes={option => 
metricAttributeOperatorDataAttributes(option.value as MetricAttributeOperator, 
row.name)}');
+    
expect(source).not.toContain('data-otlp-metrics-attribute-filter-out-action-owner="hertzbeat-ui-button"');
+    
expect(source).not.toContain('data-otlp-metrics-attribute-contains-action-owner="hertzbeat-ui-button"');
+    
expect(source).not.toContain('data-otlp-metrics-attribute-group-action-owner="hertzbeat-ui-button"');
     expect(source).toContain('boundary="top"');
     expect(source).not.toContain('className="mt-3 border-t border-[#252b35] 
pt-3"');
     
expect(source).toContain('data-otlp-metrics-linked-record-summary="log-trace-alert-links"');
@@ -3031,19 +3020,38 @@ describe('otlp metrics page', () => {
     expect(attributeTable?.textContent).toContain('checkout');
     expect(attributeTable?.textContent).toContain('route');
 
-    const serviceFilterAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-filter-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceFilterAction?.getAttribute('data-otlp-metrics-attribute-filter-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceFilterAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceFilterAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
+    const serviceOperator = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-operator-action="service.name"]')
 as HTMLElement | null;
+    expect(serviceOperator).not.toBeNull();
+    
expect(serviceOperator?.getAttribute('data-otlp-metrics-attribute-operator-action-owner')).toBe('hertzbeat-ui-select');
+    expect(serviceOperator?.getAttribute('data-hz-ui')).toBe('select');
+    expect((serviceOperator?.querySelector('[data-hz-ui="select-trigger"]') as 
HTMLButtonElement | 
null)?.getAttribute('aria-label')).toContain('service.name');
+
+    const applyAttributeOperator = async (name: string, operator: string) => {
+      const operatorRoot = 
interactionContainer.querySelector(`[data-otlp-metrics-attribute-operator-action="${name}"]`)
 as HTMLElement | null;
+      expect(operatorRoot).not.toBeNull();
+      await act(async () => {
+        (operatorRoot?.querySelector('[data-hz-ui="select-trigger"]') as 
HTMLButtonElement | null)?.click();
+        await Promise.resolve();
+      });
+      const option = operatorRoot?.querySelector(
+        
`[data-otlp-metrics-attribute-operator-option="${operator}"][data-otlp-metrics-attribute-operator-name="${name}"]`
+      ) as HTMLButtonElement | null;
+      expect(option).not.toBeNull();
+      expect(option?.getAttribute(`data-otlp-metrics-attribute-${operator === 
'exclude' ? 'filter-out' : 
operator}-action-owner`)).toBe('hertzbeat-ui-select-menu-option');
+      await act(async () => {
+        option?.click();
+        await Promise.resolve();
+      });
+      const href = String(mockState.replace.mock.calls.at(-1)?.[0]);
+      return {
+        href,
+        params: new URL(href, 'http://localhost').searchParams
+      };
+    };
 
-    const filterHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
+    const { href: filterHref, params: filterParams } = await 
applyAttributeOperator('service.name', 'filter');
     expect(filterHref).toContain('/ingestion/otlp/metrics?');
     expect(filterHref).toContain('filter=service.name%3D%22checkout%22');
-    const filterParams = new URL(filterHref, 'http://localhost').searchParams;
     expect(filterParams.get('series')).toBe('checkout_latency-0');
     expect(filterParams.get('entityId')).toBe('7');
     expect(filterParams.get('serviceName')).toBe('checkout');
@@ -3051,17 +3059,7 @@ describe('otlp metrics page', () => {
     expect(filterParams.get('traceId')).toBe('trace-checkout');
     expect(filterParams.get('spanId')).toBe('span-checkout');
 
-    const serviceContainsAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-contains-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceContainsAction?.getAttribute('data-otlp-metrics-attribute-contains-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceContainsAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceContainsAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
-
-    const containsHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const containsParams = new URL(containsHref, 
'http://localhost').searchParams;
+    const { params: containsParams } = await 
applyAttributeOperator('service.name', 'contains');
     expect(containsParams.get('filter')).toContain('service.name CONTAINS 
checkout');
     expect(containsParams.get('series')).toBe('checkout_latency-0');
     expect(containsParams.get('entityId')).toBe('7');
@@ -3070,17 +3068,7 @@ describe('otlp metrics page', () => {
     expect(containsParams.get('traceId')).toBe('trace-checkout');
     expect(containsParams.get('spanId')).toBe('span-checkout');
 
-    const serviceNotContainsAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-not-contains-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceNotContainsAction?.getAttribute('data-otlp-metrics-attribute-not-contains-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceNotContainsAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceNotContainsAction?.dispatchEvent(new MouseEvent('click', { 
bubbles: true }));
-      await Promise.resolve();
-    });
-
-    const notContainsHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const notContainsParams = new URL(notContainsHref, 
'http://localhost').searchParams;
+    const { params: notContainsParams } = await 
applyAttributeOperator('service.name', 'not-contains');
     expect(notContainsParams.get('filter')).toContain('service.name NOT 
CONTAINS checkout');
     expect(notContainsParams.get('series')).toBe('checkout_latency-0');
     expect(notContainsParams.get('entityId')).toBe('7');
@@ -3089,17 +3077,7 @@ describe('otlp metrics page', () => {
     expect(notContainsParams.get('traceId')).toBe('trace-checkout');
     expect(notContainsParams.get('spanId')).toBe('span-checkout');
 
-    const serviceInAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-in-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceInAction?.getAttribute('data-otlp-metrics-attribute-in-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceInAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceInAction?.dispatchEvent(new MouseEvent('click', { bubbles: true 
}));
-      await Promise.resolve();
-    });
-
-    const inHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const inParams = new URL(inHref, 'http://localhost').searchParams;
+    const { params: inParams } = await applyAttributeOperator('service.name', 
'in');
     expect(inParams.get('filter')).toContain('service.name IN ("checkout")');
     expect(inParams.get('series')).toBe('checkout_latency-0');
     expect(inParams.get('entityId')).toBe('7');
@@ -3108,17 +3086,7 @@ describe('otlp metrics page', () => {
     expect(inParams.get('traceId')).toBe('trace-checkout');
     expect(inParams.get('spanId')).toBe('span-checkout');
 
-    const serviceNotInAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-not-in-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceNotInAction?.getAttribute('data-otlp-metrics-attribute-not-in-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceNotInAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceNotInAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
-
-    const notInHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const notInParams = new URL(notInHref, 'http://localhost').searchParams;
+    const { params: notInParams } = await 
applyAttributeOperator('service.name', 'not-in');
     expect(notInParams.get('filter')).toContain('service.name NOT IN 
("checkout")');
     expect(notInParams.get('series')).toBe('checkout_latency-0');
     expect(notInParams.get('entityId')).toBe('7');
@@ -3127,17 +3095,7 @@ describe('otlp metrics page', () => {
     expect(notInParams.get('traceId')).toBe('trace-checkout');
     expect(notInParams.get('spanId')).toBe('span-checkout');
 
-    const serviceExcludeAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-filter-out-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceExcludeAction?.getAttribute('data-otlp-metrics-attribute-filter-out-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceExcludeAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceExcludeAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
-
-    const excludeHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const excludeParams = new URL(excludeHref, 
'http://localhost').searchParams;
+    const { params: excludeParams } = await 
applyAttributeOperator('service.name', 'exclude');
     expect(excludeParams.get('filter')).toContain('service.name!="checkout"');
     expect(excludeParams.get('series')).toBe('checkout_latency-0');
     expect(excludeParams.get('entityId')).toBe('7');
@@ -3146,17 +3104,7 @@ describe('otlp metrics page', () => {
     expect(excludeParams.get('traceId')).toBe('trace-checkout');
     expect(excludeParams.get('spanId')).toBe('span-checkout');
 
-    const serviceExistsAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-exists-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceExistsAction?.getAttribute('data-otlp-metrics-attribute-exists-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceExistsAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceExistsAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
-
-    const existsHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const existsParams = new URL(existsHref, 'http://localhost').searchParams;
+    const { params: existsParams } = await 
applyAttributeOperator('service.name', 'exists');
     expect(existsParams.get('filter')).toContain('service.name EXISTS');
     expect(existsParams.get('series')).toBe('checkout_latency-0');
     expect(existsParams.get('entityId')).toBe('7');
@@ -3165,17 +3113,7 @@ describe('otlp metrics page', () => {
     expect(existsParams.get('traceId')).toBe('trace-checkout');
     expect(existsParams.get('spanId')).toBe('span-checkout');
 
-    const serviceNotExistsAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-not-exists-action="service.name"]')
 as HTMLButtonElement | null;
-    
expect(serviceNotExistsAction?.getAttribute('data-otlp-metrics-attribute-not-exists-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(serviceNotExistsAction?.getAttribute('aria-label')).toContain('service.name');
-
-    await act(async () => {
-      serviceNotExistsAction?.dispatchEvent(new MouseEvent('click', { bubbles: 
true }));
-      await Promise.resolve();
-    });
-
-    const notExistsHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
-    const notExistsParams = new URL(notExistsHref, 
'http://localhost').searchParams;
+    const { params: notExistsParams } = await 
applyAttributeOperator('service.name', 'not-exists');
     expect(notExistsParams.get('filter')).toContain('service.name NOT EXISTS');
     expect(notExistsParams.get('series')).toBe('checkout_latency-0');
     expect(notExistsParams.get('entityId')).toBe('7');
@@ -3184,19 +3122,9 @@ describe('otlp metrics page', () => {
     expect(notExistsParams.get('traceId')).toBe('trace-checkout');
     expect(notExistsParams.get('spanId')).toBe('span-checkout');
 
-    const routeGroupAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-group-action="route"]')
 as HTMLButtonElement | null;
-    
expect(routeGroupAction?.getAttribute('data-otlp-metrics-attribute-group-action-owner')).toBe('hertzbeat-ui-button');
-    expect(routeGroupAction?.getAttribute('aria-label')).toContain('route');
-
-    await act(async () => {
-      routeGroupAction?.dispatchEvent(new MouseEvent('click', { bubbles: true 
}));
-      await Promise.resolve();
-    });
-
-    const groupHref = String(mockState.replace.mock.calls.at(-1)?.[0]);
+    const { href: groupHref, params: groupParams } = await 
applyAttributeOperator('route', 'group');
     expect(groupHref).toContain('filter=service.name%3D%22checkout%22');
     expect(groupHref).toContain('groupBy=route');
-    const groupParams = new URL(groupHref, 'http://localhost').searchParams;
     expect(groupParams.get('series')).toBe('checkout_latency-0');
     expect(groupParams.get('traceId')).toBe('trace-checkout');
     expect(groupParams.get('spanId')).toBe('span-checkout');
@@ -3282,13 +3210,24 @@ describe('otlp metrics page', () => {
       await Promise.resolve();
     });
 
-    const replaceAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-replace-action="service.name"]')
 as HTMLButtonElement | null;
+    const replaceAction = 
interactionContainer.querySelector('[data-otlp-metrics-attribute-operator-action="service.name"]')
 as HTMLElement | null;
     expect(replaceAction).not.toBeNull();
-    
expect(replaceAction?.getAttribute('data-otlp-metrics-attribute-replace-action-owner')).toBe('hertzbeat-ui-button');
-    
expect(replaceAction?.getAttribute('aria-label')).toContain('service.name');
+    
expect(replaceAction?.getAttribute('data-otlp-metrics-attribute-operator-action-owner')).toBe('hertzbeat-ui-select');
+    expect(replaceAction?.getAttribute('data-hz-ui')).toBe('select');
+    expect((replaceAction?.querySelector('[data-hz-ui="select-trigger"]') as 
HTMLButtonElement | 
null)?.getAttribute('aria-label')).toContain('service.name');
+
+    await act(async () => {
+      (replaceAction?.querySelector('[data-hz-ui="select-trigger"]') as 
HTMLButtonElement | null)?.click();
+      await Promise.resolve();
+    });
+    const replaceOption = replaceAction?.querySelector(
+      
'[data-otlp-metrics-attribute-operator-option="replace"][data-otlp-metrics-attribute-operator-name="service.name"]'
+    ) as HTMLButtonElement | null;
+    expect(replaceOption).not.toBeNull();
+    
expect(replaceOption?.getAttribute('data-otlp-metrics-attribute-replace-action-owner')).toBe('hertzbeat-ui-select-menu-option');
 
     await act(async () => {
-      replaceAction?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+      replaceOption?.click();
       await Promise.resolve();
     });
 
diff --git a/web-next/lib/i18n-runtime-messages.ts 
b/web-next/lib/i18n-runtime-messages.ts
index b83f21414a..d617911679 100644
--- a/web-next/lib/i18n-runtime-messages.ts
+++ b/web-next/lib/i18n-runtime-messages.ts
@@ -3215,6 +3215,7 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'otlp.metrics.attributes.search.placeholder': 'Search attributes',
     'otlp.metrics.attributes.column.name': 'Attribute',
     'otlp.metrics.attributes.column.value': 'Value',
+    'otlp.metrics.attributes.column.operator': 'Operator',
     'otlp.metrics.attributes.column.filter': 'Filter',
     'otlp.metrics.attributes.column.contains': 'Contains',
     'otlp.metrics.attributes.column.not-contains': 'Not contains',
@@ -3245,6 +3246,18 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'otlp.metrics.attributes.replace-action.aria': 'Replace filters with 
{{name}} equals {{value}}',
     'otlp.metrics.attributes.group-action': 'Group',
     'otlp.metrics.attributes.group-action.aria': 'Group by {{name}}',
+    'otlp.metrics.attributes.operator.placeholder': 'Choose',
+    'otlp.metrics.attributes.operator-action.aria': 'Choose a metrics 
attribute operator for {{name}} {{value}}',
+    'otlp.metrics.attributes.operator.filter': 'Filter',
+    'otlp.metrics.attributes.operator.contains': 'Contains',
+    'otlp.metrics.attributes.operator.not-contains': 'Not contains',
+    'otlp.metrics.attributes.operator.in': 'In',
+    'otlp.metrics.attributes.operator.not-in': 'Not in',
+    'otlp.metrics.attributes.operator.exclude': 'Exclude',
+    'otlp.metrics.attributes.operator.exists': 'Exists',
+    'otlp.metrics.attributes.operator.not-exists': 'Not exists',
+    'otlp.metrics.attributes.operator.replace': 'Replace',
+    'otlp.metrics.attributes.operator.group': 'Group',
     'otlp.metrics.attributes.empty': 'No attributes',
     'otlp.metrics.series.entity-id': 'entityId {{entityId}}',
     'otlp.metrics.series.entity-missing': 'Waiting for entity attribution',
@@ -7726,6 +7739,7 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'otlp.metrics.attributes.search.placeholder': '搜索属性',
     'otlp.metrics.attributes.column.name': '属性',
     'otlp.metrics.attributes.column.value': '值',
+    'otlp.metrics.attributes.column.operator': '操作',
     'otlp.metrics.attributes.column.filter': '过滤',
     'otlp.metrics.attributes.column.contains': '包含',
     'otlp.metrics.attributes.column.not-contains': '不包含',
@@ -7756,6 +7770,18 @@ export const SUPPLEMENTAL_MESSAGES: 
Partial<Record<LocaleCode, Messages>> = {
     'otlp.metrics.attributes.replace-action.aria': '替换为 {{name}} 等于 {{value}} 
的过滤条件',
     'otlp.metrics.attributes.group-action': '分组',
     'otlp.metrics.attributes.group-action.aria': '按 {{name}} 分组',
+    'otlp.metrics.attributes.operator.placeholder': '选择',
+    'otlp.metrics.attributes.operator-action.aria': '为 {{name}} {{value}} 
选择指标属性操作',
+    'otlp.metrics.attributes.operator.filter': '过滤',
+    'otlp.metrics.attributes.operator.contains': '包含',
+    'otlp.metrics.attributes.operator.not-contains': '不包含',
+    'otlp.metrics.attributes.operator.in': '属于',
+    'otlp.metrics.attributes.operator.not-in': '不属于',
+    'otlp.metrics.attributes.operator.exclude': '排除',
+    'otlp.metrics.attributes.operator.exists': '存在',
+    'otlp.metrics.attributes.operator.not-exists': '不存在',
+    'otlp.metrics.attributes.operator.replace': '替换',
+    'otlp.metrics.attributes.operator.group': '分组',
     'otlp.metrics.attributes.empty': '暂无属性',
     'otlp.metrics.series.entity-id': 'entityId {{entityId}}',
     'otlp.metrics.series.entity-missing': '等待实体归因',


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


Reply via email to