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

wusheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-banyandb.git


The following commit(s) were added to refs/heads/main by this push:
     new c6dd7e80 feat(UI): Implement the Query Page for BydbQL (#829)
c6dd7e80 is described below

commit c6dd7e80d902b614b6dbedba45520a2324427012
Author: Fine0830 <[email protected]>
AuthorDate: Wed Oct 29 12:11:27 2025 +0800

    feat(UI): Implement the Query Page for BydbQL (#829)
---
 CHANGES.md                                         |   1 +
 ui/src/api/index.js                                |   8 +
 ui/src/components/BydbQL/Index.vue                 | 562 +++++++++++++++++++++
 ui/src/components/CodeMirror/index.vue             |   7 +-
 ui/src/components/Header/components/constants.js   |  42 ++
 ui/src/components/Header/components/header.vue     | 129 ++---
 ui/src/components/Property/Editor.vue              | 236 +++++----
 ui/src/components/Property/PropertyRead.vue        |  92 +---
 ui/src/components/Property/TagEditor.vue           |  92 ----
 ui/src/components/Read/index.vue                   |  53 +-
 ui/src/components/TopNAggregation/index.vue        |  42 +-
 ui/src/components/Trace/TraceRead.vue              |  96 +---
 ui/src/components/common/MeasureAndStreamTable.vue | 136 +++++
 .../{Property => common}/PropertyEditor.vue        | 219 ++++----
 ui/src/components/common/PropertyTable.vue         | 179 +++++++
 .../PropertyValueViewer.vue}                       |  59 ++-
 ui/src/components/common/TagEditor.vue             | 161 ++++++
 ui/src/components/common/TopNTable.vue             | 198 ++++++++
 ui/src/components/common/TraceTable.vue            | 168 ++++++
 ui/src/components/common/data.js                   |   2 +
 ui/src/main.js                                     |   8 +-
 ui/src/router/index.js                             |   8 +-
 ui/src/views/Query/BydbQL.vue                      | 140 +++++
 23 files changed, 2029 insertions(+), 609 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 8f0e61d1..73fb4557 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -52,6 +52,7 @@ Release Notes.
 - Use Fetch request to instead of axios request and remove axios.
 - Implement Trace Tree for debug mode.
 - Implement bydbQL.
+- UI: Implement the Query Page for BydbQL.
 
 ### Bug Fixes
 
diff --git a/ui/src/api/index.js b/ui/src/api/index.js
index eeb693bb..7492d060 100644
--- a/ui/src/api/index.js
+++ b/ui/src/api/index.js
@@ -222,3 +222,11 @@ export function queryTraces(json) {
     method: 'POST',
   });
 }
+
+export function executeBydbQLQuery(data) {
+  return httpQuery({
+    url: `/api/v1/bydbql/query`,
+    json: data,
+    method: 'POST',
+  });
+}
diff --git a/ui/src/components/BydbQL/Index.vue 
b/ui/src/components/BydbQL/Index.vue
new file mode 100644
index 00000000..69d4dc0b
--- /dev/null
+++ b/ui/src/components/BydbQL/Index.vue
@@ -0,0 +1,562 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+<script setup>
+  import { ref, computed, onMounted, nextTick } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import { executeBydbQLQuery } from '@/api/index';
+  import CodeMirror from '@/components/CodeMirror/index.vue';
+  import TopNTable from '@/components/common/TopNTable.vue';
+  import MeasureAndStreamTable from 
'@/components/common/MeasureAndStreamTable.vue';
+  import PropertyTable from '@/components/common/PropertyTable.vue';
+  import TraceTable from '@/components/common/TraceTable.vue';
+  import { CatalogToGroupType } from '@/components/common/data';
+
+  // Default query text with example queries as comments
+  const queryText = ref(`-- Example queries:
+-- Query stream logs from the last 30 minutes
+-- SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'
+-- Query traces from the last 30 minutes
+-- SELECT () FROM TRACE segment in sw_trace TIME > '-30m' order by latency desc
+
+SELECT * FROM STREAM log in sw_recordsLog TIME > '-30m'`);
+  const queryResult = ref(null);
+  const loading = ref(false);
+  const error = ref(null);
+  const executionTime = ref(0);
+
+  const hasResult = computed(() => queryResult.value !== null);
+  const resultType = computed(() => {
+    if (!queryResult.value) return null;
+    if (queryResult.value.streamResult) return 
CatalogToGroupType.CATALOG_STREAM;
+    if (queryResult.value.measureResult) return 
CatalogToGroupType.CATALOG_MEASURE;
+    if (queryResult.value.propertyResult) return 
CatalogToGroupType.CATALOG_PROPERTY;
+    if (queryResult.value.traceResult) return CatalogToGroupType.CATALOG_TRACE;
+    if (queryResult.value.topnResult) return CatalogToGroupType.CATALOG_TOPN;
+    return 'unknown';
+  });
+  // Transform query results into table format
+  const tableData = computed(() => {
+    if (!queryResult.value) return [];
+
+    try {
+      // Handle TopN results differently
+      if (queryResult.value.topnResult?.lists) {
+        const topnLists = queryResult.value.topnResult.lists;
+        const rows = topnLists
+          .map((list) =>
+            (list.items || []).map((item) => ({
+              label: item.entity?.[0]?.value?.str?.value || 'N/A',
+              value: item.value?.int?.value ?? item.value?.float?.value ?? 
'N/A',
+              timestamp: list.timestamp || item.timestamp,
+            })),
+          )
+          .flat();
+        return rows;
+      }
+
+      let elements = [];
+      if (queryResult.value.streamResult?.elements) {
+        elements = queryResult.value.streamResult.elements;
+      }
+      if (queryResult.value.measureResult?.dataPoints) {
+        elements = queryResult.value.measureResult.dataPoints;
+      }
+      if (queryResult.value.traceResult?.traces) {
+        elements = queryResult.value.traceResult.traces;
+      }
+      if (queryResult.value.propertyResult?.properties) {
+        elements = queryResult.value.propertyResult.properties;
+      }
+
+      if (!elements || elements.length === 0) return [];
+
+      // Transform elements to table rows
+      const rows = elements.map((item) => {
+        const row = {};
+
+        // Process tag families
+        if (item.tag_families || item.tagFamilies) {
+          const tagFamilies = item.tag_families || item.tagFamilies;
+          tagFamilies.forEach((family) => {
+            const tags = family.tags || [];
+            tags.forEach((tag) => {
+              const key = tag.key;
+              const value = tag.value;
+              if (value) {
+                const typeKeys = Object.keys(value);
+                for (const typeKey of typeKeys) {
+                  if (value[typeKey] !== undefined && value[typeKey] !== null) 
{
+                    if (typeof value[typeKey] === 'object' && 
value[typeKey].value !== undefined) {
+                      row[key] = value[typeKey].value;
+                    } else {
+                      row[key] = value[typeKey];
+                    }
+                    break;
+                  }
+                }
+              }
+
+              if (row[key] === undefined || row[key] === null) {
+                row[key] = 'Null';
+              }
+            });
+          });
+        }
+        // Process fields for measure results
+        if (item.fields) {
+          item.fields.forEach((field) => {
+            const name = field.name;
+            const value = field.value;
+
+            if (value) {
+              const typeKeys = Object.keys(value);
+              for (const typeKey of typeKeys) {
+                if (value[typeKey] !== undefined && value[typeKey] !== null) {
+                  if (typeof value[typeKey] === 'object' && 
value[typeKey].value !== undefined) {
+                    row[name] = value[typeKey].value;
+                  } else {
+                    row[name] = value[typeKey];
+                  }
+                  break;
+                }
+              }
+            }
+            if (row[name] === undefined || row[name] === null) {
+              row[name] = 'Null';
+            }
+          });
+        }
+        row.timestamp = item.timestamp;
+        return row;
+      });
+
+      return rows;
+    } catch (e) {
+      console.error('Error parsing table data:', e);
+      return [];
+    }
+  });
+
+  // Generate table columns from the first row of data
+  const tableColumns = computed(() => {
+    if (!tableData.value || tableData.value.length === 0) return [];
+
+    const firstRow = tableData.value[0];
+    const columns = [];
+
+    Object.keys(firstRow).forEach((key) => {
+      if (key !== 'timestamp') {
+        columns.push({
+          name: key,
+          label: key,
+          prop: key,
+          type: Array.isArray(firstRow[key]) ? 'TAG_TYPE_STRING_ARRAY' : 
'TAG_TYPE_STRING',
+        });
+      }
+    });
+
+    return columns;
+  });
+
+  // Determine if we should use pagination based on result type
+  const shouldTopNResult = computed(() => resultType.value === 
CatalogToGroupType.CATALOG_TOPN);
+  const shouldPropertyResult = computed(() => resultType.value === 
CatalogToGroupType.CATALOG_PROPERTY);
+  const shouldTraceResult = computed(() => resultType.value === 
CatalogToGroupType.CATALOG_TRACE);
+
+  // Transform property results into table format
+  const propertyData = computed(() => {
+    if (!queryResult.value || !queryResult.value.propertyResult) return [];
+
+    const properties = queryResult.value.propertyResult.properties || [];
+    return properties.map((item) => {
+      // Clone and process tags to stringify values
+      const processedItem = { ...item };
+      if (processedItem.tags) {
+        processedItem.tags = processedItem.tags.map((tag) => ({
+          ...tag,
+          value: JSON.stringify(tag.value),
+        }));
+      }
+      return processedItem;
+    });
+  });
+
+  // Transform trace results into table format for TraceTable
+  const traceTableData = computed(() => {
+    if (!queryResult.value || !queryResult.value.traceResult?.traces) return 
[];
+
+    const traces = queryResult.value.traceResult.traces;
+    return traces
+      .map((trace) => {
+        return (trace.spans || []).map((span) => {
+          const tagsMap = {};
+          for (const tag of span.tags || []) {
+            tagsMap[tag.key] = tag.value;
+          }
+          return {
+            traceId: trace.traceId,
+            ...span,
+            ...tagsMap,
+          };
+        });
+      })
+      .flat();
+  });
+
+  // Extract span tags for TraceTable columns
+  const spanTags = computed(() => {
+    if (!queryResult.value || !queryResult.value.traceResult?.traces) return 
[];
+
+    const tags = new Set();
+    const traces = queryResult.value.traceResult.traces;
+    traces.forEach((trace) => {
+      (trace.spans || []).forEach((span) => {
+        (span.tags || []).forEach((tag) => {
+          tags.add(tag.key);
+        });
+      });
+    });
+    return Array.from(tags);
+  });
+
+  async function executeQuery() {
+    if (!queryText.value.trim()) {
+      ElMessage.warning('Please enter a query');
+      return;
+    }
+
+    loading.value = true;
+    error.value = null;
+    queryResult.value = null;
+    const startTime = performance.now();
+
+    // Remove comment lines (lines starting with --) before executing
+    const cleanQuery = queryText.value
+      .split('\n')
+      .filter((line) => !line.trim().startsWith('--'))
+      .join('\n')
+      .trim();
+
+    const response = await executeBydbQLQuery({ query: cleanQuery });
+    const endTime = performance.now();
+    loading.value = false;
+    executionTime.value = Math.round(endTime - startTime);
+
+    if (response.error) {
+      error.value = response.error.message || 'Failed to execute query';
+      ElMessage.error(error.value);
+      return;
+    }
+    queryResult.value = response;
+    ElMessage.success(`Query executed successfully in 
${executionTime.value}ms`);
+  }
+
+  function clearQuery() {
+    queryText.value = '';
+    queryResult.value = null;
+    error.value = null;
+    executionTime.value = 0;
+  }
+  // Setup keyboard shortcuts for CodeMirror
+  onMounted(() => {
+    nextTick(() => {
+      // Find the CodeMirror instance and add keyboard shortcuts
+      const codeMirrorElement = document.querySelector('.query-input 
.CodeMirror');
+      if (codeMirrorElement && codeMirrorElement.CodeMirror) {
+        const cm = codeMirrorElement.CodeMirror;
+        cm.setOption('extraKeys', {
+          'Ctrl-Enter': executeQuery,
+          'Cmd-Enter': executeQuery,
+        });
+      }
+    });
+  });
+</script>
+
+<template>
+  <div class="query-container">
+    <el-card shadow="always" class="query-card">
+      <template #header>
+        <div class="card-header">
+          <span class="header-title">BydbQL Query Console</span>
+          <div class="header-actions">
+            <el-button @click="clearQuery" size="small" :disabled="loading"> 
Clear </el-button>
+            <el-button type="primary" @click="executeQuery" :loading="loading" 
size="small"> Execute Query </el-button>
+          </div>
+        </div>
+      </template>
+      <div class="query-input-container">
+        <CodeMirror
+          v-model="queryText"
+          :mode="'sql'"
+          :lint="false"
+          :readonly="false"
+          :style-active-line="true"
+          :auto-refresh="true"
+          class="query-input"
+        />
+      </div>
+    </el-card>
+    <el-card shadow="always" class="result-card">
+      <template #header>
+        <div>
+          <el-tag v-if="resultType" size="small" class="result-type-tag">
+            {{ resultType.toUpperCase() }}
+          </el-tag>
+        </div>
+      </template>
+      <div v-if="error" class="error-message">
+        <el-alert title="Error" type="error" :description="error" show-icon 
:closable="false" />
+      </div>
+      <div v-if="!hasResult && !error">
+        <el-empty description="No result" />
+      </div>
+      <div class="result-table" v-if="hasResult">
+        <!-- Use PropertyTable for Property results -->
+        <PropertyTable v-if="shouldPropertyResult" :data="propertyData" 
:border="true" @refresh="executeQuery" />
+        <!-- Use TopNTable for TopN results -->
+        <TopNTable
+          v-else-if="shouldTopNResult"
+          :data="tableData"
+          :columns="tableColumns"
+          :loading="false"
+          :page-size="20"
+          :show-selection="false"
+          :show-index="false"
+          :show-timestamp="false"
+          :show-pagination="true"
+          :stripe="true"
+          :border="true"
+          empty-text="No data yet"
+          min-height="300px"
+        />
+        <!-- Use TraceTable for Trace results -->
+        <TraceTable
+          v-else-if="shouldTraceResult"
+          :data="traceTableData"
+          :span-tags="spanTags"
+          :border="true"
+          :show-selection="false"
+          :enable-merge="true"
+          empty-text="No trace data found"
+        />
+        <!-- Use MeasureAndStreamTable for other result types -->
+        <MeasureAndStreamTable
+          v-else
+          :table-data="tableData"
+          :loading="false"
+          :table-header="tableColumns"
+          :show-selection="false"
+          :show-index="true"
+          :show-timestamp="true"
+          empty-text="No data yet"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .query-container {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+  }
+
+  .query-card,
+  .result-card,
+  .info-card {
+    width: 100%;
+  }
+
+  .card-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 10px;
+  }
+
+  .header-title {
+    font-size: 18px;
+    font-weight: 600;
+  }
+
+  .header-actions {
+    display: flex;
+    gap: 10px;
+  }
+
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+  }
+
+  .result-type-tag {
+    font-weight: 600;
+  }
+
+  .execution-time {
+    font-size: 14px;
+    color: #606266;
+  }
+
+  .result-table {
+    overflow: auto;
+    max-height: 46vh;
+    min-height: 100px;
+  }
+
+  .query-input-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  .input-label {
+    font-weight: 500;
+    font-size: 14px;
+    color: #303133;
+  }
+
+  .input-hint {
+    font-size: 12px;
+    color: #909399;
+  }
+
+  .query-input-container {
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    overflow: hidden;
+
+    :deep(.in-coder-panel) {
+      height: 150px;
+    }
+
+    :deep(.CodeMirror) {
+      border: none;
+      height: 100%;
+      font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+      font-size: 14px;
+      line-height: 1.6;
+    }
+
+    :deep(.CodeMirror-scroll) {
+      min-height: 150px;
+    }
+  }
+
+  .examples-section {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    padding: 12px;
+    background-color: #f5f7fa;
+    border-radius: 4px;
+  }
+
+  .examples-label {
+    font-size: 13px;
+    font-weight: 500;
+    color: #606266;
+  }
+
+  .example-button {
+    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+    font-size: 12px;
+  }
+
+  .result-section {
+    min-height: 100px;
+  }
+
+  .error-message {
+    margin-bottom: 16px;
+  }
+
+  .result-content {
+    background-color: #f5f7fa;
+    border-radius: 4px;
+    padding: 16px;
+    overflow: auto;
+    max-height: 600px;
+  }
+
+  .result-json {
+    margin: 0;
+    font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+    font-size: 13px;
+    line-height: 1.6;
+    color: #303133;
+    white-space: pre-wrap;
+    word-break: break-word;
+  }
+
+  .info-content {
+    font-size: 14px;
+    line-height: 1.8;
+    color: #606266;
+
+    p {
+      margin: 0 0 12px 0;
+    }
+
+    ul {
+      margin: 12px 0;
+      padding-left: 24px;
+
+      li {
+        margin: 8px 0;
+
+        strong {
+          color: #303133;
+        }
+      }
+    }
+
+    .info-note {
+      margin-top: 16px;
+      padding: 12px;
+      background-color: #f0f9ff;
+      border-left: 4px solid #409eff;
+      border-radius: 4px;
+      font-size: 13px;
+      color: #303133;
+    }
+  }
+
+  @media (max-width: 768px) {
+    .card-header {
+      flex-direction: column;
+      align-items: flex-start;
+    }
+
+    .header-actions {
+      width: 100%;
+      justify-content: flex-end;
+    }
+
+    .examples-section {
+      flex-direction: column;
+      align-items: flex-start;
+    }
+  }
+</style>
diff --git a/ui/src/components/CodeMirror/index.vue 
b/ui/src/components/CodeMirror/index.vue
index ae928b67..48403d9a 100644
--- a/ui/src/components/CodeMirror/index.vue
+++ b/ui/src/components/CodeMirror/index.vue
@@ -28,9 +28,10 @@
   import CodeMirror from 'codemirror';
   import 'codemirror/lib/codemirror.css';
   import 'codemirror/mode/yaml/yaml.js';
+  import 'codemirror/mode/sql/sql.js';
   import 'codemirror/mode/css/css.js';
   import 'codemirror/addon/lint/yaml-lint.js';
-  import 'codemirror/theme/rubyblue.css';
+  import 'codemirror/theme/dracula.css';
   import jsYaml from 'js-yaml';
   window.jsyaml = jsYaml;
   export default {
@@ -54,7 +55,7 @@
       },
       theme: {
         type: String,
-        default: 'rubyblue',
+        default: 'dracula',
       },
       styleActiveLine: {
         type: Boolean,
@@ -134,7 +135,7 @@
     width: 100%;
     height: 100%;
     :deep(.CodeMirror) {
-      border: 1px solid #eee;
+      border: 1px solid #44475a;
       height: 100%;
       width: 100%;
       .CodeMirror-code {
diff --git a/ui/src/components/Header/components/constants.js 
b/ui/src/components/Header/components/constants.js
new file mode 100644
index 00000000..62a1b447
--- /dev/null
+++ b/ui/src/components/Header/components/constants.js
@@ -0,0 +1,42 @@
+/*
+ * Licensed to Apache Software Foundation (ASF) under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Apache Software Foundation (ASF) licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export const MENU_ACTIVE_COLOR = 'var(--color-main)';
+export const MENU_DEFAULT_PATH = '/banyandb/query';
+
+// Menu configuration
+export const MenuConfig = [
+  {
+    index: '/banyandb/query',
+    label: 'Query',
+  },
+  {
+    index: 'management',
+    label: 'Management',
+    children: [
+      { index: '/banyandb/stream', label: 'Stream' },
+      { index: '/banyandb/measure', label: 'Measure' },
+      { index: '/banyandb/trace', label: 'Trace' },
+      { index: '/banyandb/property', label: 'Property' },
+    ],
+  },
+  {
+    index: '/banyandb/dashboard',
+    label: 'Monitoring',
+  },
+];
diff --git a/ui/src/components/Header/components/header.vue 
b/ui/src/components/Header/components/header.vue
index 6c5c17bb..fe9912fa 100644
--- a/ui/src/components/Header/components/header.vue
+++ b/ui/src/components/Header/components/header.vue
@@ -18,124 +18,129 @@
 -->
 
 <script setup>
-  import { reactive, watch, getCurrentInstance } from 'vue';
-  import { ElImage, ElMenu, ElMenuItem } from 'element-plus';
+  import { ref, watch } from 'vue';
+  import { ElImage, ElMenu, ElMenuItem, ElSubMenu } from 'element-plus';
   import { useRoute } from 'vue-router';
   import userImg from '@/assets/banyandb_small.jpg';
+  import { MENU_ACTIVE_COLOR, MENU_DEFAULT_PATH, MenuConfig } from 
'./constants';
 
-  // Eventbus
-  const $bus = getCurrentInstance().appContext.config.globalProperties.mittBus;
-
-  // router
   const route = useRoute();
+  const getActiveMenu = (path) => {
+    const parts = path.split('/').filter(Boolean);
+    return parts.length >= 2 ? `/${parts[0]}/${parts[1]}` : MENU_DEFAULT_PATH;
+  };
+  const activeMenu = ref(getActiveMenu(route.path));
 
-  // data
-  const data = reactive({
-    activeMenu: '/banyandb/dashboard',
-  });
-
-  // watch
   watch(
-    () => route,
-    () => {
-      let arr = route.path.split('/');
-      data.activeMenu = `/${arr[1]}/${arr[2]}`;
-    },
-    {
-      immediate: true,
-      deep: true,
+    () => route.path,
+    (newPath) => {
+      activeMenu.value = getActiveMenu(newPath);
     },
+    { immediate: true },
   );
-
-  // function
-  function initData() {
-    let arr = route.path.split('/');
-    data.activeMenu = `/${arr[1]}/${arr[2]}`;
-  }
-
-  initData();
 </script>
 
 <template>
   <div class="size flex align-item-center justify-between bd-bottom">
-    <div class="image flex align-item-center justify-between">
+    <div class="image">
       <el-image :src="userImg" class="flex center" fit="fill">
         <div slot="error" class="image-slot">
           <i class="el-icon-picture-outline"></i>
         </div>
       </el-image>
-      <div class="title text-main-color text-title text-family 
text-weight-lt">BanyanDB Manager</div>
-      <!-- stream/measure sources url -->
-      <div style="width: 380px" class="margin-left-small"></div>
+      <span class="title text-main-color text-title text-family 
text-weight-lt" role="img" aria-label="BanyanDB Manager"
+        >BanyanDB Manager</span
+      >
     </div>
-    <div class="navigation" style="margin-right: 20%">
+    <nav class="navigation">
       <el-menu
-        active-text-color="#6E38F7"
+        :active-text-color="MENU_ACTIVE_COLOR"
         router
         :ellipsis="false"
-        class="el-menu-demo"
         mode="horizontal"
-        :default-active="data.activeMenu"
+        :default-active="activeMenu"
       >
-        <el-menu-item index="/banyandb/dashboard">Dashboard</el-menu-item>
-        <el-menu-item index="/banyandb/stream">Stream</el-menu-item>
-        <el-menu-item index="/banyandb/measure">Measure</el-menu-item>
-        <el-menu-item index="/banyandb/trace">Trace</el-menu-item>
-        <el-menu-item index="/banyandb/property">Property</el-menu-item>
+        <template v-for="item in MenuConfig" :key="item.index">
+          <el-sub-menu v-if="item.children" :index="item.index">
+            <template #title>{{ item.label }}</template>
+            <el-menu-item v-for="child in item.children" :key="child.index" 
:index="child.index">
+              {{ child.label }}
+            </el-menu-item>
+          </el-sub-menu>
+          <el-menu-item v-else :index="item.index">{{ item.label 
}}</el-menu-item>
+        </template>
       </el-menu>
-    </div>
+    </nav>
     <div class="flex-block"> </div>
   </div>
 </template>
 
 <style lang="scss" scoped>
+  $header-height: 60px;
+  $logo-size: 59px;
+
   .image {
     display: flex;
     align-items: center;
-    justify-content: space-between;
-    width: 665px;
+    gap: 10px;
     height: 100%;
 
     .el-image {
-      width: 59px;
-      height: 59px;
+      width: $logo-size;
+      height: $logo-size;
       flex-shrink: 0;
-      flex-grow: 0;
     }
 
     .title {
       height: 100%;
-      line-height: 59px;
-      flex-shrink: 0;
-      flex-grow: 0;
+      line-height: $logo-size;
       white-space: nowrap;
-      margin-left: 10px;
+      margin: 0;
+      font-size: inherit;
+    }
+  }
+
+  .navigation {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 20%;
+
+    :deep(.el-menu) {
+      display: flex;
+      align-items: center;
+      border-bottom: none;
+      height: $header-height;
     }
   }
 
-  .el-menu-item {
+  .el-menu-item,
+  :deep(.el-sub-menu__title) {
     font-weight: var(--weight-lt);
     font-size: var(--size-lt);
     font-family: var(--font-family-main);
-  }
+    height: $header-height;
+    line-height: $header-height;
+    display: flex;
+    align-items: center;
 
-  .el-menu-item:hover {
-    color: var(--el-menu-active-color) !important;
+    &:hover {
+      color: var(--el-menu-active-color) !important;
+    }
   }
 
-  .navigation {
+  :deep(.el-sub-menu) {
+    height: $header-height;
     display: flex;
     align-items: center;
-    justify-content: center;
+  }
+
+  :deep(.el-sub-menu__title) {
+    border-bottom: none;
   }
 
   .flex-block {
     width: 140px;
     margin-right: 30px;
   }
-
-  .icon-size {
-    width: 25px;
-    height: 25px;
-  }
 </style>
diff --git a/ui/src/components/Property/Editor.vue 
b/ui/src/components/Property/Editor.vue
index 2909a4aa..3665e40b 100644
--- a/ui/src/components/Property/Editor.vue
+++ b/ui/src/components/Property/Editor.vue
@@ -17,21 +17,35 @@
   ~ under the License.
 -->
 <script setup>
-  import { reactive, ref, onMounted, getCurrentInstance } from 'vue';
+  import { reactive, ref, onMounted, computed, getCurrentInstance } from 'vue';
   import { ElMessage } from 'element-plus';
   import { useRoute, useRouter } from 'vue-router';
   import { updateProperty, createProperty, getResourceOfAllType } from 
'@/api/index';
-  import TagEditor from './TagEditor.vue';
+  import TagEditor from '@/components/common/TagEditor.vue';
   import { rules, strategyGroup, formConfig } from './data';
 
-  const $loadingCreate = 
getCurrentInstance().appContext.config.globalProperties.$loadingCreate;
-  const $loadingClose = 
getCurrentInstance().appContext.config.globalProperties.$loadingClose;
-  const $bus = getCurrentInstance().appContext.config.globalProperties.mittBus;
+  // Constants
+  const OPERATOR_MODE = {
+    CREATE: 'create',
+    EDIT: 'edit',
+  };
+
+  // Composables
+  const { appContext } = getCurrentInstance();
+  const $loadingCreate = appContext.config.globalProperties.$loadingCreate;
+  const $loadingClose = appContext.config.globalProperties.$loadingClose;
+  const $bus = appContext.config.globalProperties.mittBus;
   const route = useRoute();
   const router = useRouter();
+
+  // Refs
   const tagEditorRef = ref();
   const ruleForm = ref();
+
+  // Extract route params
   const { operator, name, group, type } = route.params;
+
+  // Reactive form data
   const formData = reactive({
     strategy: strategyGroup[0].value,
     group: group || '',
@@ -41,111 +55,149 @@
     tags: [],
   });
 
-  function initProperty() {
-    if (operator === 'edit') {
+  // Computed properties
+  const isCreateMode = computed(() => operator === OPERATOR_MODE.CREATE);
+  const isEditMode = computed(() => operator === OPERATOR_MODE.EDIT);
+
+  // Initialize property data for edit mode
+  async function initProperty() {
+    if (!isEditMode.value) return;
+
+    try {
       $loadingCreate();
-      getResourceOfAllType(type, group, name)
-        .then((res) => {
-          if (res.status === 200) {
-            const { property } = res.data;
-
-            formData.tags = property.tags.map((d) => ({
-              ...d,
-              key: d.name,
-              value: d.type,
-            }));
-          }
-        })
-        .finally(() => {
-          $loadingClose();
-        });
+      const res = await getResourceOfAllType(type, group, name);
+
+      if (res.status === 200 && res.data?.property) {
+        const { property } = res.data;
+        formData.tags = property.tags.map((tag) => ({
+          ...tag,
+          key: tag.name,
+          value: tag.type,
+        }));
+      }
+    } catch (error) {
+      ElMessage.error({
+        message: `Failed to load property: ${error.message || 'Unknown 
error'}`,
+        type: 'error',
+      });
+    } finally {
+      $loadingClose();
     }
   }
-  const openEditTag = (index) => {
-    tagEditorRef.value.openDialog(formData.tags[index]).then((res) => {
-      formData.tags[index] = res;
-    });
+
+  // Tag management functions with error handling
+  const openEditTag = async (index) => {
+    try {
+      const result = await tagEditorRef.value.openDialog(formData.tags[index]);
+      formData.tags[index] = result;
+    } catch (error) {
+      // User cancelled, do nothing
+    }
   };
+
   const deleteTag = (index) => {
     formData.tags.splice(index, 1);
   };
-  const openAddTag = () => {
-    tagEditorRef.value.openDialog().then((res) => {
-      formData.tags.push(res);
-    });
-  };
-  const submit = async () => {
-    if (!ruleForm.value) return;
-    await ruleForm.value.validate(async (valid) => {
-      if (valid) {
-        $loadingCreate();
-        const param = {
-          strategy: formData.strategy,
-          property: {
-            id: formData.id,
-            metadata: {
-              group: formData.group,
-              name: formData.name,
-            },
-            tags: formData.tags.map((d) => ({ name: d.key, type: d.value })),
-          },
-        };
-        if (operator === 'create') {
-          const response = await createProperty(param);
-          $loadingClose();
-          if (response.error) {
-            ElMessage.error({
-              message: `Failed to create property: ${response.error.message}`,
-              type: 'error',
-            });
-            return;
-          }
-          ElMessage.success({
-            message: 'Create successed',
-            type: 'success',
-          });
-          $bus.emit('refreshAside');
-          $bus.emit('deleteResource', formData.name);
-          openResourses();
-          return;
-        }
-        const response = await updateProperty(formData.group, formData.name, 
param);
-        $loadingClose();
-        if (response.error) {
-          ElMessage.error({
-            message: `Failed to update property: ${response.error.message}`,
-            type: 'error',
-          });
-          return;
-        }
-        ElMessage.success({
-          message: 'Update successed',
-          type: 'success',
-        });
-        $bus.emit('refreshAside');
-        $bus.emit('deleteResource', formData.name);
-        openResourses();
-      }
-    });
+
+  const openAddTag = async () => {
+    try {
+      const result = await tagEditorRef.value.openDialog();
+      formData.tags.push(result);
+    } catch (error) {
+      // User cancelled, do nothing
+    }
   };
-  function openResourses() {
-    const route = {
+
+  // Build property payload
+  const buildPropertyPayload = () => ({
+    strategy: formData.strategy,
+    property: {
+      id: formData.id,
+      metadata: {
+        group: formData.group,
+        name: formData.name,
+      },
+      tags: formData.tags.map(({ key, value }) => ({
+        name: key,
+        type: value,
+      })),
+    },
+  });
+
+  // Navigate to resource view
+  function openResources() {
+    const targetRoute = {
       name: formData.type,
       params: {
         group: formData.group,
         name: formData.name,
         operator: 'read',
-        type: formData.type + '',
+        type: String(formData.type),
       },
     };
-    router.push(route);
-    const add = {
+
+    router.push(targetRoute);
+
+    $bus.emit('AddTabs', {
       label: formData.name,
       type: 'Read',
-      route,
-    };
-    $bus.emit('AddTabs', add);
+      route: targetRoute,
+    });
   }
+
+  // Submit form with improved error handling
+  const submit = async () => {
+    if (!ruleForm.value) return;
+
+    try {
+      const isValid = await ruleForm.value.validate();
+      if (!isValid) return;
+
+      $loadingCreate();
+
+      const payload = buildPropertyPayload();
+      let response;
+
+      if (isCreateMode.value) {
+        response = await createProperty(payload);
+
+        if (response.error) {
+          throw new Error(response.error.message);
+        }
+
+        ElMessage.success({
+          message: 'Property created successfully',
+          type: 'success',
+        });
+      } else {
+        response = await updateProperty(formData.group, formData.name, 
payload);
+
+        if (response.error) {
+          throw new Error(response.error.message);
+        }
+
+        ElMessage.success({
+          message: 'Property updated successfully',
+          type: 'success',
+        });
+      }
+
+      // Emit events and navigate
+      $bus.emit('refreshAside');
+      $bus.emit('deleteResource', formData.name);
+      openResources();
+    } catch (error) {
+      const action = isCreateMode.value ? 'create' : 'update';
+      ElMessage.error({
+        message: `Failed to ${action} property: ${error.message || 'Unknown 
error'}`,
+        type: 'error',
+      });
+    } finally {
+      $loadingClose();
+    }
+  };
+
+  // Initialize on mount
   onMounted(() => {
     initProperty();
   });
diff --git a/ui/src/components/Property/PropertyRead.vue 
b/ui/src/components/Property/PropertyRead.vue
index 3aba2344..ffdf34ee 100644
--- a/ui/src/components/Property/PropertyRead.vue
+++ b/ui/src/components/Property/PropertyRead.vue
@@ -22,21 +22,18 @@
   import { ElMessage } from 'element-plus';
   import { reactive, ref, watch, onMounted, getCurrentInstance } from 'vue';
   import { RefreshRight, Search, TrendCharts } from '@element-plus/icons-vue';
-  import { fetchProperties, deleteProperty } from '@/api/index';
+  import { fetchProperties } from '@/api/index';
   import { yamlToJson } from '@/utils/yaml';
   import CodeMirror from '@/components/CodeMirror/index.vue';
-  import PropertyEditor from './PropertyEditor.vue';
-  import PropertyValueReader from './PropertyValueReader.vue';
   import FormHeader from '../common/FormHeader.vue';
   import TraceTree from '../TraceTree/TraceContent.vue';
+  import PropertyTable from '@/components/common/PropertyTable.vue';
 
   const { proxy } = getCurrentInstance();
   // Loading
   const route = useRoute();
   const $loadingCreate = 
getCurrentInstance().appContext.config.globalProperties.$loadingCreate;
   const $loadingClose = proxy.$loadingClose;
-  const propertyEditorRef = ref();
-  const propertyValueViewerRef = ref();
   const yamlRef = ref(null);
   const data = reactive({
     group: route.params.group,
@@ -66,27 +63,6 @@ limit: 10`);
       return item;
     });
   };
-  const openPropertyView = (data) => {
-    propertyValueViewerRef?.value.openDialog(data);
-  };
-
-  const ellipsizeValueData = (data) => {
-    return data.value.slice(0, 20) + '...';
-  };
-  const openEditField = (index) => {
-    const item = data.tableData[index];
-    const param = {
-      group: item.metadata.group,
-      name: item.metadata.name,
-      modRevision: item.metadata.modRevision,
-      createRevision: item.metadata.createRevision,
-      id: item.id,
-      tags: JSON.parse(JSON.stringify(item.tags)),
-    };
-    propertyEditorRef?.value.openDialog(true, param).then(() => {
-      getProperties();
-    });
-  };
 
   function searchProperties() {
     yamlRef.value
@@ -105,24 +81,7 @@ limit: 10`);
         });
       });
   }
-  const deleteTableData = async (index) => {
-    const item = data.tableData[index];
-    $loadingCreate();
-    const response = await deleteProperty(item.metadata.group, 
item.metadata.name, item.id);
-    $loadingClose();
-    if (response.error) {
-      ElMessage({
-        message: `Failed to delete property: ${response.error.message}`,
-        type: 'error',
-      });
-      return;
-    }
-    ElMessage({
-      message: 'successed',
-      type: 'success',
-    });
-    getProperties();
-  };
+
   onMounted(() => {
     getProperties();
   });
@@ -154,51 +113,8 @@ limit: 10`;
           <span>Debug Trace</span>
         </el-button>
       </div>
-      <el-table :data="data.tableData" style="width: 100%" border>
-        <el-table-column label="Group" prop="metadata.group" 
width="100"></el-table-column>
-        <el-table-column label="Name" prop="metadata.name" 
width="120"></el-table-column>
-        <el-table-column label="ModRevision" prop="metadata.modRevision" 
width="120"></el-table-column>
-        <el-table-column label="CreateRevision" prop="metadata.createRevision" 
width="140"></el-table-column>
-        <el-table-column label="ID" prop="id" width="150"></el-table-column>
-        <el-table-column label="Tags">
-          <template #default="scope">
-            <el-table :data="scope.row.tags">
-              <el-table-column label="Key" prop="key" 
width="150"></el-table-column>
-              <el-table-column label="Value" prop="value">
-                <template #default="scope">
-                  {{ ellipsizeValueData(scope.row) }}
-                  <el-button
-                    link
-                    type="primary"
-                    @click.prevent="openPropertyView(scope.row)"
-                    style="color: var(--color-main); font-weight: bold"
-                    >view</el-button
-                  >
-                </template>
-              </el-table-column>
-            </el-table>
-          </template>
-        </el-table-column>
-        <el-table-column label="Operator" width="150">
-          <template #default="scope">
-            <el-button
-              link
-              type="primary"
-              @click.prevent="openEditField(scope.$index)"
-              style="color: var(--color-main); font-weight: bold"
-              >Edit</el-button
-            >
-            <el-popconfirm @confirm="deleteTableData(scope.$index)" title="Are 
you sure to delete this?">
-              <template #reference>
-                <el-button link type="danger" style="color: red; font-weight: 
bold">Delete</el-button>
-              </template>
-            </el-popconfirm>
-          </template>
-        </el-table-column>
-      </el-table>
+      <PropertyTable :data="data.tableData" :border="true" 
:show-operator="true" @refresh="getProperties" />
     </el-card>
-    <PropertyEditor ref="propertyEditorRef"></PropertyEditor>
-    <PropertyValueReader ref="propertyValueViewerRef"></PropertyValueReader>
   </div>
   <el-dialog
     v-model="showTracesDialog"
diff --git a/ui/src/components/Property/TagEditor.vue 
b/ui/src/components/Property/TagEditor.vue
deleted file mode 100644
index 19c2b756..00000000
--- a/ui/src/components/Property/TagEditor.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<!--
-  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
-  ~ license agreements. See the NOTICE file distributed with
-  ~ this work for additional information regarding copyright
-  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
-  ~ the Apache License, Version 2.0 (the "License"); you may
-  ~ not use this file except in compliance with the License.
-  ~ You may obtain a copy of the License at
-  ~
-  ~     http://www.apache.org/licenses/LICENSE-2.0
-  ~
-  ~ Unless required by applicable law or agreed to in writing,
-  ~ software distributed under the License is distributed on an
-  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-  ~ KIND, either express or implied.  See the License for the
-  ~ specific language governing permissions and limitations
-  ~ under the License.
--->
-
-<script setup>
-  import { reactive, ref } from 'vue';
-
-  const showDialog = ref(false);
-  const ruleForm = ref();
-  const title = ref('');
-  const formData = reactive({
-    key: '',
-    value: '',
-  });
-  let promiseResolve;
-  const initData = () => {
-    formData.key = '';
-    formData.value = '';
-  };
-  const closeDialog = () => {
-    initData();
-    showDialog.value = false;
-  };
-  const confirmApply = async () => {
-    if (!ruleForm.value) return;
-    await ruleForm.value.validate((valid) => {
-      if (valid) {
-        promiseResolve(JSON.parse(JSON.stringify(formData)));
-        initData();
-        showDialog.value = false;
-      }
-    });
-  };
-  const openDialog = (data) => {
-    if (data) {
-      formData.key = data.key;
-      formData.value = data.value;
-      title.value = 'Edit Tag';
-    } else {
-      title.value = 'Add Tag';
-    }
-    showDialog.value = true;
-    return new Promise((resolve) => {
-      promiseResolve = resolve;
-    });
-  };
-  defineExpose({
-    openDialog,
-  });
-</script>
-
-<template>
-  <el-dialog v-model="showDialog" :title="title" width="30%">
-    <el-form ref="ruleForm" :model="formData" label-position="left">
-      <el-form-item label="Key" prop="key" required label-width="150">
-        <el-input v-model="formData.key" autocomplete="off"></el-input>
-      </el-form-item>
-      <el-form-item label="Value" prop="value" required label-width="150">
-        <el-input v-model="formData.value" type="textarea" 
autocomplete="off"></el-input>
-      </el-form-item>
-    </el-form>
-    <template #footer>
-      <span class="dialog-footer footer">
-        <el-button @click="closeDialog">Cancel</el-button>
-        <el-button type="primary" @click="confirmApply"> Confirm </el-button>
-      </span>
-    </template>
-  </el-dialog>
-</template>
-
-<style scoped lang="scss">
-  .footer {
-    width: 100%;
-    display: flex;
-    justify-content: center;
-  }
-</style>
diff --git a/ui/src/components/Read/index.vue b/ui/src/components/Read/index.vue
index 333b6e3b..a26f848c 100644
--- a/ui/src/components/Read/index.vue
+++ b/ui/src/components/Read/index.vue
@@ -28,6 +28,7 @@
   import { Shortcuts, Last15Minutes } from '../common/data';
   import { CatalogToGroupType } from '../GroupTree/data';
   import TraceTree from '../TraceTree/TraceContent.vue';
+  import MeasureAndStreamTable from '../common/MeasureAndStreamTable.vue';
 
   const route = useRoute();
   const yamlRef = ref();
@@ -415,49 +416,15 @@ orderBy:
           <span>Debug Trace</span>
         </el-button>
       </div>
-      <el-table
-        v-loading="data.loading"
-        element-loading-text="loading"
-        element-loading-spinner="el-icon-loading"
-        element-loading-background="rgba(0, 0, 0, 0.8)"
-        ref="multipleTable"
-        stripe
-        :border="true"
-        highlight-current-row
-        tooltip-effect="dark"
-        empty-text="No data yet"
-        :data="data.tableData"
-      >
-        <el-table-column type="selection" width="55"> </el-table-column>
-        <el-table-column type="index" label="number" width="90"> 
</el-table-column>
-        <el-table-column label="timestamp" width="260" key="timestamp" 
prop="timestamp"></el-table-column>
-        <el-table-column
-          v-for="item in tableHeader"
-          sortable
-          :key="item.name"
-          :label="item.label"
-          :prop="item.name"
-          show-overflow-tooltip
-        >
-          <template #default="scope">
-            <el-popover
-              v-if="(item.type || item.fieldType)?.includes(`ARRAY`) && 
scope.row[item.name] !== `Null`"
-              effect="dark"
-              trigger="hover"
-              placement="top"
-              width="auto"
-            >
-              <template #default>
-                <div>{{ scope.row[item.name].join('; ') }}</div>
-              </template>
-              <template #reference>
-                <el-tag>View</el-tag>
-              </template>
-            </el-popover>
-            <div v-else>{{ scope.row[item.name] }}</div>
-          </template>
-        </el-table-column>
-      </el-table>
+      <MeasureAndStreamTable
+        :tableData="data.tableData"
+        :tableHeader="tableHeader"
+        :loading="data.loading"
+        :showSelection="true"
+        :showIndex="true"
+        :showTimestamp="true"
+        emptyText="No data yet"
+      />
     </el-card>
   </div>
   <el-dialog
diff --git a/ui/src/components/TopNAggregation/index.vue 
b/ui/src/components/TopNAggregation/index.vue
index e959ecaa..ed15e1af 100644
--- a/ui/src/components/TopNAggregation/index.vue
+++ b/ui/src/components/TopNAggregation/index.vue
@@ -26,10 +26,10 @@
   import { getTopNAggregationData } from '@/api/index';
   import CodeMirror from '@/components/CodeMirror/index.vue';
   import FormHeader from '../common/FormHeader.vue';
+  import TopNTable from '../common/TopNTable.vue';
   import { Shortcuts, Last15Minutes } from '../common/data';
   import TraceTree from '../TraceTree/TraceContent.vue';
 
-  const pageSize = 10;
   const route = useRoute();
   const data = reactive({
     group: '',
@@ -42,10 +42,15 @@
   const timeRange = ref([]);
   const yamlCode = ref('');
   const loading = ref(false);
-  const currentList = ref([]);
   const showTracesDialog = ref(false);
   const traceData = ref(null);
 
+  // Table columns configuration
+  const columns = [
+    { label: 'Label', prop: 'label' },
+    { label: 'Value', prop: 'value', width: '220' },
+  ];
+
   function initTopNAggregationData() {
     if (!(data.type && data.group && data.name)) {
       return;
@@ -86,7 +91,6 @@ fieldValueSort: 1`;
     data.lists = (result.lists || [])
       .map((d) => d.items.map((item) => ({ label: 
item.entity[0].value.str.value, value: item.value.int.value })))
       .flat();
-    changePage(0);
   }
 
   function searchTopNAggregation() {
@@ -120,12 +124,6 @@ fieldValueSort: 1`;
     yamlCode.value = jsonToYaml(json.data).data;
   }
 
-  function changePage(pageIndex) {
-    currentList.value = data.lists.filter(
-      (d, index) => (pageIndex - 1 || 0) * pageSize <= index && pageSize * 
(pageIndex || 1) > index,
-    );
-  }
-
   watch(
     () => route,
     () => {
@@ -177,25 +175,15 @@ fieldValueSort: 1`;
           <span>Debug Trace</span>
         </el-button>
       </div>
-      <el-table
-        :data="currentList"
-        style="width: 100%; margin: 10px 0; min-height: 440px"
-        stripe
+      <TopNTable
+        :data="data.lists"
+        :columns="columns"
+        :loading="loading"
+        :page-size="10"
+        :stripe="true"
         :border="true"
-        highlight-current-row
-        tooltip-effect="dark"
-      >
-        <el-table-column prop="label" label="Label" />
-        <el-table-column prop="value" label="Value" width="220" />
-      </el-table>
-      <el-pagination
-        background
-        layout="prev, pager, next"
-        :page-size="pageSize"
-        :total="data.lists.length"
-        @current-change="changePage"
-        @prev-click="changePage"
-        @next-click="changePage"
+        :show-pagination="true"
+        empty-text="No data yet"
       />
     </el-card>
   </div>
diff --git a/ui/src/components/Trace/TraceRead.vue 
b/ui/src/components/Trace/TraceRead.vue
index 5e0cc55c..fba75829 100644
--- a/ui/src/components/Trace/TraceRead.vue
+++ b/ui/src/components/Trace/TraceRead.vue
@@ -30,6 +30,7 @@
   import { Last15Minutes, Shortcuts } from '../common/data';
   import JSZip from 'jszip';
   import TraceTree from '../TraceTree/TraceContent.vue';
+  import TraceTable from '../common/TraceTable.vue';
 
   const { proxy } = getCurrentInstance();
   const route = useRoute();
@@ -253,62 +254,6 @@ orderBy:
     }
   }
 
-  const getTagValue = (data) => {
-    let value = data.value;
-
-    const isNullish = (val) => val === null || val === undefined || val === 
'null';
-    if (isNullish(value)) {
-      return 'N/A';
-    }
-    for (let i = 0; i < 2; i++) {
-      if (typeof value !== 'object') {
-        const strValue = value.toString();
-        return strValue.length > 100 ? strValue.substring(0, 150) + '...' : 
strValue;
-      }
-      for (const key in value) {
-        if (Object.hasOwn(value, key)) {
-          value = value[key];
-          break;
-        }
-      }
-      if (isNullish(value)) {
-        return 'N/A';
-      }
-    }
-
-    const strValue = value.toString();
-    return strValue.length > 100 ? strValue.substring(0, 150) + '...' : 
strValue;
-  };
-
-  const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
-    // Only merge the traceId column (first column after selection)
-    if (columnIndex === 1) {
-      const currentTraceId = row.traceId;
-      // Check if this is the first row with this traceId
-      if (rowIndex === 0 || data.tableData[rowIndex - 1].traceId !== 
currentTraceId) {
-        // Count how many rows have the same traceId
-        let rowspan = 1;
-        for (let i = rowIndex + 1; i < data.tableData.length; i++) {
-          if (data.tableData[i].traceId === currentTraceId) {
-            rowspan++;
-          } else {
-            break;
-          }
-        }
-        return {
-          rowspan: rowspan,
-          colspan: 1,
-        };
-      } else {
-        // This row's traceId is merged with a previous row
-        return {
-          rowspan: 0,
-          colspan: 0,
-        };
-      }
-    }
-  };
-
   watch(
     () => route,
     () => {
@@ -367,32 +312,14 @@ orderBy:
             <el-button :icon="Download" @click="downloadMultipleSpans"> 
Download Selected </el-button>
           </div>
         </div>
-        <div class="table-container">
-          <el-table
-            :data="data.tableData"
-            :border="true"
-            style="width: 100%"
-            @selection-change="handleSelectionChange"
-            :span-method="objectSpanMethod"
-          >
-            <el-table-column type="selection" width="55" fixed />
-            <el-table-column label="traceId" prop="traceId" width="200" fixed>
-              <template #default="scope">
-                {{ getTagValue({ value: scope.row.traceId }) }}
-              </template>
-            </el-table-column>
-            <el-table-column label="spanId" prop="spanId" width="300" fixed>
-              <template #default="scope">
-                {{ getTagValue({ value: scope.row.spanId }) }}
-              </template>
-            </el-table-column>
-            <el-table-column v-for="tag in data.spanTags" :key="tag" 
:label="tag" :prop="tag" min-width="200">
-              <template #default="scope">
-                {{ getTagValue({ value: scope.row[tag] }) }}
-              </template>
-            </el-table-column>
-          </el-table>
-        </div>
+        <TraceTable
+          :data="data.tableData"
+          :span-tags="data.spanTags"
+          :border="true"
+          :show-selection="true"
+          :enable-merge="true"
+          @selection-change="handleSelectionChange"
+        />
       </div>
       <el-empty v-else description="No trace data found" style="margin-top: 
20px" />
     </el-card>
@@ -426,9 +353,4 @@ orderBy:
     word-break: break-all;
     font-size: 12px;
   }
-
-  .table-container {
-    overflow-x: auto;
-    width: 100%;
-  }
 </style>
diff --git a/ui/src/components/common/MeasureAndStreamTable.vue 
b/ui/src/components/common/MeasureAndStreamTable.vue
new file mode 100644
index 00000000..29173339
--- /dev/null
+++ b/ui/src/components/common/MeasureAndStreamTable.vue
@@ -0,0 +1,136 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+<script setup>
+  import { defineProps, ref, computed } from 'vue';
+
+  const props = defineProps({
+    tableData: {
+      type: Array,
+      default: () => [],
+    },
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    tableHeader: {
+      type: Array,
+      default: () => [],
+    },
+    showSelection: {
+      type: Boolean,
+      default: true,
+    },
+    showIndex: {
+      type: Boolean,
+      default: true,
+    },
+    showTimestamp: {
+      type: Boolean,
+      default: true,
+    },
+    emptyText: {
+      type: String,
+      default: 'No data yet',
+    },
+  });
+
+  const currentPage = ref(1);
+  const pageSize = ref(10);
+
+  const paginatedData = computed(() => {
+    const start = (currentPage.value - 1) * pageSize.value;
+    const end = start + pageSize.value;
+    return props.tableData.slice(start, end);
+  });
+
+  const handleCurrentChange = (val) => {
+    currentPage.value = val;
+  };
+</script>
+
+<template>
+  <div class="measure-and-stream-table">
+    <el-table
+      v-loading="loading"
+      element-loading-text="loading"
+      element-loading-spinner="el-icon-loading"
+      element-loading-background="rgba(0, 0, 0, 0.8)"
+      ref="multipleTable"
+      stripe
+      :border="true"
+      highlight-current-row
+      tooltip-effect="dark"
+      :empty-text="emptyText"
+      :data="paginatedData"
+      style="width: 100%"
+    >
+      <el-table-column v-if="showSelection" type="selection" width="55"> 
</el-table-column>
+      <el-table-column v-if="showIndex" type="index" label="number" 
width="90"> </el-table-column>
+      <el-table-column
+        v-if="showTimestamp"
+        label="timestamp"
+        width="260"
+        key="timestamp"
+        prop="timestamp"
+      ></el-table-column>
+      <el-table-column
+        v-for="item in tableHeader"
+        sortable
+        :key="item.name"
+        :label="item.label"
+        :prop="item.name"
+        show-overflow-tooltip
+      >
+        <template #default="scope">
+          <el-popover
+            v-if="(item.type || item.fieldType)?.includes(`ARRAY`) && 
scope.row[item.name] !== `Null`"
+            effect="dark"
+            trigger="hover"
+            placement="top"
+            width="auto"
+          >
+            <template #default>
+              <div>{{ scope.row[item.name].join('; ') }}</div>
+            </template>
+            <template #reference>
+              <el-tag>View</el-tag>
+            </template>
+          </el-popover>
+          <div v-else>{{ scope.row[item.name] }}</div>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div style="margin-top: 20px; display: flex; justify-content: flex-end">
+      <el-pagination
+        v-model:current-page="currentPage"
+        :page-size="pageSize"
+        :total="tableData.length"
+        layout="total, prev, pager, next, jumper"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .measure-and-stream-table {
+    width: 100%;
+    overflow-x: auto;
+  }
+</style>
diff --git a/ui/src/components/Property/PropertyEditor.vue 
b/ui/src/components/common/PropertyEditor.vue
similarity index 50%
rename from ui/src/components/Property/PropertyEditor.vue
rename to ui/src/components/common/PropertyEditor.vue
index 39226ba1..869b3986 100644
--- a/ui/src/components/Property/PropertyEditor.vue
+++ b/ui/src/components/common/PropertyEditor.vue
@@ -18,19 +18,25 @@
 -->
 
 <script setup>
-  import { reactive, ref, getCurrentInstance } from 'vue';
+  import { reactive, ref, computed, getCurrentInstance } from 'vue';
   import { ElMessage } from 'element-plus';
-  import TagEditor from './TagEditor.vue';
-  import { applyProperty } from '@/api';
-  import { rules, strategyGroup, formConfig } from './data';
+  import TagEditor from '@/components/common/TagEditor.vue';
+  import { applyProperty } from '@/api/index';
+  import { rules, strategyGroup, formConfig } from 
'@/components/Property/data';
 
+  const EDITOR_MODE = {
+    CREATE: 'create',
+    EDIT: 'edit',
+  };
+  const DIALOG_TITLES = {
+    [EDITOR_MODE.CREATE]: 'Create Property',
+    [EDITOR_MODE.EDIT]: 'Edit Property',
+  };
+  const { proxy } = getCurrentInstance();
   const $loadingCreate = 
getCurrentInstance().appContext.config.globalProperties.$loadingCreate;
-  const $loadingClose = 
getCurrentInstance().appContext.config.globalProperties.$loadingClose;
-  const showDialog = ref(false);
-  const title = ref('');
-  const tagEditorRef = ref();
-  const ruleForm = ref();
-  const formData = reactive({
+  const $loadingClose = proxy.$loadingClose;
+  const emit = defineEmits(['refresh', 'close']);
+  const createInitialFormData = () => ({
     strategy: strategyGroup[0].value,
     group: '',
     name: '',
@@ -39,100 +45,133 @@
     id: '',
     tags: [],
   });
-  let promiseResolve;
-
-  const initData = () => {
-    formData.strategy = strategyGroup[0].value;
-    formData.group = '';
-    formData.name = '';
-    formData.modRevision = 0;
-    formData.createRevision = 0;
-    formData.id = '';
-    formData.tags = [];
+
+  const showDialog = ref(false);
+  const editorMode = ref(EDITOR_MODE.CREATE);
+  const tagEditorRef = ref();
+  const ruleForm = ref();
+  const formData = reactive(createInitialFormData());
+
+  const editorTitle = computed(() => DIALOG_TITLES[editorMode.value]);
+  const resetFormData = () => {
+    Object.assign(formData, createInitialFormData());
   };
-  const closeDialog = () => {
+  const deepClone = (obj) => {
+    try {
+      return structuredClone(obj);
+    } catch (e) {
+      // Fallback for older browsers
+      return JSON.parse(JSON.stringify(obj));
+    }
+  };
+
+  const openEditor = (propertyData = null) => {
+    if (propertyData) {
+      editorMode.value = EDITOR_MODE.EDIT;
+      formData.group = propertyData.metadata.group;
+      formData.name = propertyData.metadata.name;
+      formData.modRevision = propertyData.metadata.modRevision;
+      formData.createRevision = propertyData.metadata.createRevision;
+      formData.id = propertyData.id;
+      formData.tags = deepClone(propertyData.tags);
+    } else {
+      editorMode.value = EDITOR_MODE.CREATE;
+      resetFormData();
+    }
+    showDialog.value = true;
+  };
+
+  const closeEditor = () => {
     showDialog.value = false;
-    initData();
+    ruleForm.value?.resetFields();
+    resetFormData();
+    emit('close');
   };
-  const openEditTag = (index) => {
-    tagEditorRef.value.openDialog(formData.tags[index]).then((res) => {
-      formData.tags[index].key = res.key;
-      formData.tags[index].value = res.value;
-    });
+
+  // Tag management functions with error handling.
+  const openEditTag = async (index) => {
+    try {
+      const result = await tagEditorRef.value.openDialog(formData.tags[index]);
+      formData.tags[index] = { ...formData.tags[index], ...result };
+    } catch (error) {
+      // User cancelled the dialog, do nothing
+    }
   };
+
   const deleteTag = (index) => {
     formData.tags.splice(index, 1);
   };
-  const openAddTag = () => {
-    tagEditorRef.value.openDialog().then((res) => {
-      formData.tags.push(res);
-    });
+
+  const openAddTag = async () => {
+    try {
+      const result = await tagEditorRef.value.openDialog();
+      formData.tags.push(result);
+    } catch (error) {
+      // User cancelled the dialog, do nothing
+    }
   };
+
+  const buildPropertyPayload = () => ({
+    strategy: formData.strategy,
+    property: {
+      id: formData.id,
+      metadata: {
+        createRevision: formData.createRevision,
+        group: formData.group,
+        modRevision: formData.modRevision,
+        name: formData.name,
+      },
+      tags: formData.tags.map(({ key, value }) => ({
+        key,
+        value: JSON.parse(value),
+      })),
+    },
+  });
+
+  // Confirm and apply property with improved error handling.
   const confirmApply = async () => {
     if (!ruleForm.value) return;
-    await ruleForm.value.validate(async (valid) => {
-      if (valid) {
-        $loadingCreate();
-        const param = {
-          strategy: formData.strategy,
-          property: {
-            id: formData.id,
-            metadata: {
-              createRevision: formData.createRevision,
-              group: formData.group,
-              modRevision: formData.modRevision,
-              name: formData.name,
-            },
-            tags: formData.tags.map((item) => {
-              return {
-                key: item.key,
-                value: JSON.parse(item.value),
-              };
-            }),
-          },
-        };
-        const response = await applyProperty(formData.group, formData.name, 
formData.id, param);
-        $loadingClose();
-        if (response.error) {
-          ElMessage({
-            message: `Failed to apply property: ${response.error.message}`,
-            type: 'error',
-          });
-          return;
-        }
-        ElMessage({
-          message: 'successed',
-          type: 'success',
-        });
-        showDialog.value = false;
-        promiseResolve();
+
+    try {
+      const isValid = await ruleForm.value.validate();
+      if (!isValid) return;
+
+      $loadingCreate();
+
+      const payload = buildPropertyPayload();
+      const response = await applyProperty(formData.group, formData.name, 
formData.id, payload);
+
+      if (response.error) {
+        throw new Error(response.error.message);
       }
-    });
-  };
-  const openDialog = (edit, data) => {
-    showDialog.value = true;
-    if (edit === true) {
-      title.value = 'Edit Property';
-    } else {
-      title.value = 'Apply Property';
+
+      ElMessage({
+        message: 'Property applied successfully',
+        type: 'success',
+      });
+
+      showDialog.value = false;
+      emit('refresh');
+      resetFormData();
+    } catch (error) {
+      ElMessage({
+        message: `Failed to apply property: ${error.message || 'Unknown 
error'}`,
+        type: 'error',
+      });
+    } finally {
+      $loadingClose();
     }
-    formData.group = data?.group || '';
-    formData.name = data?.name || '';
-    formData.modRevision = data?.modRevision || 0;
-    formData.createRevision = data?.createRevision || 0;
-    formData.id = data?.id || '';
-    formData.tags = JSON.parse(JSON.stringify(data?.tags || []));
-    return new Promise((resolve) => {
-      promiseResolve = resolve;
-    });
   };
+
   defineExpose({
-    openDialog,
+    openEditor,
+    closeEditor,
   });
 </script>
 
 <template>
-  <el-dialog v-model="showDialog" :title="title" width="50%">
+  <!-- Property Editor Dialog -->
+  <el-dialog v-model="showDialog" :title="editorTitle" width="80%" 
style="height: 75vh">
     <el-form ref="ruleForm" :rules="rules" :model="formData" 
label-position="left">
       <el-form-item v-for="item in formConfig" :key="item.prop" 
:label="item.label" :prop="item.prop" label-width="200">
         <el-select
@@ -158,8 +197,8 @@
       </el-form-item>
       <el-form-item label="Tags" prop="tags" label-width="200">
         <el-button size="small" type="primary" color="#6E38F7" 
@click="openAddTag">Add Tag</el-button>
-        <el-table style="margin-top: 10px" :data="formData.tags" border>
-          <el-table-column label="Key" prop="key"></el-table-column>
+        <el-table style="margin-top: 10px; height: 40vh; overflow: auto" 
:data="formData.tags" border>
+          <el-table-column label="Key" prop="key" 
width="200"></el-table-column>
           <el-table-column label="Value" prop="value"></el-table-column>
           <el-table-column label="Operator" width="150">
             <template #default="scope">
@@ -182,11 +221,13 @@
     </el-form>
     <template #footer>
       <span class="dialog-footer footer">
-        <el-button @click="closeDialog">Cancel</el-button>
+        <el-button @click="closeEditor">Cancel</el-button>
         <el-button type="primary" @click="confirmApply"> Confirm </el-button>
       </span>
     </template>
   </el-dialog>
+
+  <!-- Tag Editor Dialog -->
   <TagEditor ref="tagEditorRef"></TagEditor>
 </template>
 
diff --git a/ui/src/components/common/PropertyTable.vue 
b/ui/src/components/common/PropertyTable.vue
new file mode 100644
index 00000000..892039a9
--- /dev/null
+++ b/ui/src/components/common/PropertyTable.vue
@@ -0,0 +1,179 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+<script setup>
+  import { ref, getCurrentInstance } from 'vue';
+  import { ElMessage } from 'element-plus';
+  import PropertyEditor from '@/components/common/PropertyEditor.vue';
+  import PropertyValueViewer from 
'@/components/common/PropertyValueViewer.vue';
+  import { deleteProperty } from '@/api/index';
+
+  const { proxy } = getCurrentInstance();
+  const $loadingCreate = 
getCurrentInstance().appContext.config.globalProperties.$loadingCreate;
+  const $loadingClose = proxy.$loadingClose;
+
+  const props = defineProps({
+    // Table data
+    data: {
+      type: Array,
+      default: () => [],
+    },
+    // Loading state
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    // Table empty text
+    emptyText: {
+      type: String,
+      default: 'No data yet',
+    },
+    // Show operator column (Edit/Delete)
+    showOperator: {
+      type: Boolean,
+      default: true,
+    },
+    // Border style
+    border: {
+      type: Boolean,
+      default: true,
+    },
+    // Stripe style
+    stripe: {
+      type: Boolean,
+      default: false,
+    },
+    // Max characters to show before ellipsis for tag values
+    maxValueLength: {
+      type: Number,
+      default: 20,
+    },
+  });
+
+  const emit = defineEmits(['refresh']);
+
+  // Component references
+  const propertyEditorRef = ref();
+  const propertyValueViewerRef = ref();
+
+  const ellipsizeValueData = (data) => {
+    if (!data.value || data.value.length <= props.maxValueLength) {
+      return data.value;
+    }
+    return data.value.slice(0, props.maxValueLength) + '...';
+  };
+
+  // Property Value Viewer function
+  const handleViewValue = (tagData) => {
+    propertyValueViewerRef.value.openViewer(tagData);
+  };
+
+  // Property Editor functions
+  const handleEdit = (index) => {
+    const item = props.data[index];
+    propertyEditorRef.value.openEditor(item);
+  };
+
+  const handleEditorRefresh = () => {
+    emit('refresh');
+  };
+
+  const handleDelete = async (index) => {
+    const item = props.data[index];
+    $loadingCreate();
+    const response = await deleteProperty(item.metadata.group, 
item.metadata.name, item.id);
+    $loadingClose();
+    if (response.error) {
+      ElMessage({
+        message: `Failed to delete property: ${response.error.message}`,
+        type: 'error',
+      });
+      return;
+    }
+    ElMessage({
+      message: 'successed',
+      type: 'success',
+    });
+    emit('refresh');
+  };
+</script>
+
+<template>
+  <el-table
+    v-loading="loading"
+    element-loading-text="loading"
+    element-loading-spinner="el-icon-loading"
+    element-loading-background="rgba(0, 0, 0, 0.8)"
+    :data="data"
+    style="width: 100%"
+    :border="border"
+    :stripe="stripe"
+    :empty-text="emptyText"
+  >
+    <el-table-column label="Group" prop="metadata.group" 
width="100"></el-table-column>
+    <el-table-column label="Name" prop="metadata.name" 
width="120"></el-table-column>
+    <el-table-column label="ModRevision" prop="metadata.modRevision" 
width="120"></el-table-column>
+    <el-table-column label="CreateRevision" prop="metadata.createRevision" 
width="140"></el-table-column>
+    <el-table-column label="ID" prop="id" width="150"></el-table-column>
+    <el-table-column label="Tags">
+      <template #default="scope">
+        <el-table :data="scope.row.tags">
+          <el-table-column label="Key" prop="key" 
width="150"></el-table-column>
+          <el-table-column label="Value" prop="value">
+            <template #default="scope">
+              {{ ellipsizeValueData(scope.row) }}
+              <el-button
+                link
+                type="primary"
+                @click.prevent="handleViewValue(scope.row)"
+                style="color: var(--color-main); font-weight: bold"
+                >view</el-button
+              >
+            </template>
+          </el-table-column>
+        </el-table>
+      </template>
+    </el-table-column>
+    <el-table-column v-if="showOperator" label="Operator" width="150">
+      <template #default="scope">
+        <el-button
+          link
+          type="primary"
+          @click.prevent="handleEdit(scope.$index)"
+          style="color: var(--color-main); font-weight: bold"
+          >Edit</el-button
+        >
+        <el-popconfirm @confirm="handleDelete(scope.$index)" title="Are you 
sure to delete this?">
+          <template #reference>
+            <el-button link type="danger" style="color: red; font-weight: 
bold">Delete</el-button>
+          </template>
+        </el-popconfirm>
+      </template>
+    </el-table-column>
+  </el-table>
+
+  <!-- Property Value Viewer Component -->
+  <PropertyValueViewer ref="propertyValueViewerRef"></PropertyValueViewer>
+
+  <!-- Property Editor Component -->
+  <PropertyEditor ref="propertyEditorRef" 
@refresh="handleEditorRefresh"></PropertyEditor>
+</template>
+
+<style lang="scss" scoped>
+  /* Styles are now handled by child components */
+</style>
diff --git a/ui/src/components/Property/PropertyValueReader.vue 
b/ui/src/components/common/PropertyValueViewer.vue
similarity index 56%
rename from ui/src/components/Property/PropertyValueReader.vue
rename to ui/src/components/common/PropertyValueViewer.vue
index 7bcf9f5f..5131d34c 100644
--- a/ui/src/components/Property/PropertyValueReader.vue
+++ b/ui/src/components/common/PropertyValueViewer.vue
@@ -20,46 +20,65 @@
 <script setup>
   import { reactive, ref } from 'vue';
 
+  const emit = defineEmits(['close']);
+
+  // Viewer state
   const showDialog = ref(false);
-  const title = ref('');
+  const dialogTitle = ref('');
   const valueData = reactive({
     data: '',
     formattedData: '',
   });
-
   const numSpaces = 2;
-  const closeDialog = () => {
+
+  // Open viewer with tag data
+  const openViewer = (tagData) => {
+    dialogTitle.value = 'Value of key ' + tagData.key;
+    showDialog.value = true;
+    valueData.data = tagData.value;
+    try {
+      valueData.formattedData = JSON.stringify(JSON.parse(valueData.data), 
null, numSpaces);
+    } catch (error) {
+      // If value is not valid JSON, display as-is
+      valueData.formattedData = valueData.data;
+    }
+  };
+
+  // Close viewer dialog
+  const closeViewer = () => {
     showDialog.value = false;
+    valueData.data = '';
+    valueData.formattedData = '';
+    emit('close');
   };
 
+  // Download value as text file
   const downloadValue = () => {
-    const dataBlob = new Blob([valueData.formattedData], { type: 'text/JSON' 
});
-    var a = document.createElement('a');
-    a.download = 'value.txt';
+    const dataBlob = new Blob([valueData.formattedData], { type: 'text/plain' 
});
+    const a = document.createElement('a');
+    a.download = 'property-value.txt';
     a.href = URL.createObjectURL(dataBlob);
     document.body.appendChild(a);
     a.click();
     document.body.removeChild(a);
+    URL.revokeObjectURL(a.href);
   };
 
-  const openDialog = (data) => {
-    title.value = 'Value of key ' + data.key;
-    showDialog.value = true;
-    valueData.data = data.value;
-    valueData.formattedData = JSON.stringify(JSON.parse(valueData.data), null, 
numSpaces);
-  };
+  // Expose methods to parent component
   defineExpose({
-    openDialog,
+    openViewer,
+    closeViewer,
   });
 </script>
 
 <template>
-  <el-dialog v-model="showDialog" :title="title">
-    <div class="configuration">{{ valueData.formattedData }}</div>
+  <!-- Property Value Viewer Dialog -->
+  <el-dialog v-model="showDialog" :title="dialogTitle" width="50%">
+    <div class="value-content">{{ valueData.formattedData }}</div>
     <template #footer>
       <span class="dialog-footer footer">
-        <el-button @click="closeDialog">Cancel</el-button>
-        <el-button type="primary" @click.prevent="downloadValue()"> Download 
</el-button>
+        <el-button @click="closeViewer">Cancel</el-button>
+        <el-button type="primary" @click.prevent="downloadValue"> Download 
</el-button>
       </span>
     </template>
   </el-dialog>
@@ -71,10 +90,14 @@
     display: flex;
     justify-content: center;
   }
-  .configuration {
+  .value-content {
     width: 100%;
     overflow: auto;
     max-height: 700px;
     white-space: pre;
+    font-family: monospace;
+    background-color: #f5f5f5;
+    padding: 15px;
+    border-radius: 4px;
   }
 </style>
diff --git a/ui/src/components/common/TagEditor.vue 
b/ui/src/components/common/TagEditor.vue
new file mode 100644
index 00000000..fa543181
--- /dev/null
+++ b/ui/src/components/common/TagEditor.vue
@@ -0,0 +1,161 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+
+<script setup>
+  import { reactive, ref, computed } from 'vue';
+
+  // Constants
+  const DIALOG_MODE = {
+    ADD: 'add',
+    EDIT: 'edit',
+  };
+
+  const DIALOG_TITLES = {
+    [DIALOG_MODE.ADD]: 'Add Tag',
+    [DIALOG_MODE.EDIT]: 'Edit Tag',
+  };
+
+  // Validation rules
+  const rules = {
+    key: [
+      { required: true, message: 'Please enter tag key', trigger: 'blur' },
+      { min: 1, max: 100, message: 'Key length should be 1-100 characters', 
trigger: 'blur' },
+    ],
+    value: [{ required: true, message: 'Please enter tag value', trigger: 
'blur' }],
+  };
+
+  // Factory function for initial form data
+  const createInitialFormData = () => ({
+    key: '',
+    value: '',
+  });
+
+  // Refs
+  const showDialog = ref(false);
+  const dialogMode = ref(DIALOG_MODE.ADD);
+  const ruleForm = ref();
+  const formData = reactive(createInitialFormData());
+
+  // Promise handlers
+  let promiseResolve = null;
+  let promiseReject = null;
+
+  // Computed properties
+  const dialogTitle = computed(() => DIALOG_TITLES[dialogMode.value]);
+
+  // Reset form data to initial state
+  const resetFormData = () => {
+    Object.assign(formData, createInitialFormData());
+  };
+
+  // Close dialog and cleanup
+  const closeDialog = () => {
+    showDialog.value = false;
+    ruleForm.value?.resetFields();
+    resetFormData();
+
+    // Reject promise if user cancels
+    if (promiseReject) {
+      promiseReject(new Error('User cancelled'));
+      promiseReject = null;
+    }
+    promiseResolve = null;
+  };
+
+  // Confirm and return data
+  const confirmApply = async () => {
+    if (!ruleForm.value) return;
+
+    try {
+      const isValid = await ruleForm.value.validate();
+      if (!isValid) return;
+
+      // Return a copy of the data
+      const result = { ...formData };
+
+      if (promiseResolve) {
+        promiseResolve(result);
+        promiseResolve = null;
+        promiseReject = null;
+      }
+
+      showDialog.value = false;
+      resetFormData();
+    } catch (error) {
+      // Validation failed, do nothing
+    }
+  };
+
+  // Open dialog for add/edit
+  const openDialog = (data = null) => {
+    if (data) {
+      dialogMode.value = DIALOG_MODE.EDIT;
+      formData.key = data.key;
+      formData.value = data.value;
+    } else {
+      dialogMode.value = DIALOG_MODE.ADD;
+      resetFormData();
+    }
+
+    showDialog.value = true;
+
+    return new Promise((resolve, reject) => {
+      promiseResolve = resolve;
+      promiseReject = reject;
+    });
+  };
+
+  // Expose methods
+  defineExpose({
+    openDialog,
+  });
+</script>
+
+<template>
+  <el-dialog v-model="showDialog" :title="dialogTitle" width="30%">
+    <el-form ref="ruleForm" :rules="rules" :model="formData" 
label-position="left">
+      <el-form-item label="Key" prop="key" label-width="150">
+        <el-input v-model="formData.key" autocomplete="off" placeholder="Enter 
tag key"></el-input>
+      </el-form-item>
+      <el-form-item label="Value" prop="value" label-width="150">
+        <el-input
+          v-model="formData.value"
+          type="textarea"
+          :rows="4"
+          autocomplete="off"
+          placeholder="Enter tag value (JSON format)"
+        ></el-input>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer footer">
+        <el-button @click="closeDialog">Cancel</el-button>
+        <el-button type="primary" @click="confirmApply"> Confirm </el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<style scoped lang="scss">
+  .footer {
+    width: 100%;
+    display: flex;
+    justify-content: center;
+  }
+</style>
diff --git a/ui/src/components/common/TopNTable.vue 
b/ui/src/components/common/TopNTable.vue
new file mode 100644
index 00000000..74051ac9
--- /dev/null
+++ b/ui/src/components/common/TopNTable.vue
@@ -0,0 +1,198 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+<script setup>
+  import { ref, computed, watch } from 'vue';
+
+  const props = defineProps({
+    // Full data array
+    data: {
+      type: Array,
+      default: () => [],
+    },
+    // Table columns configuration
+    columns: {
+      type: Array,
+      default: () => [],
+    },
+    // Loading state
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    // Page size
+    pageSize: {
+      type: Number,
+      default: 10,
+    },
+    // Table empty text
+    emptyText: {
+      type: String,
+      default: 'No data yet',
+    },
+    // Show selection column
+    showSelection: {
+      type: Boolean,
+      default: false,
+    },
+    // Show index column
+    showIndex: {
+      type: Boolean,
+      default: false,
+    },
+    // Show timestamp column
+    showTimestamp: {
+      type: Boolean,
+      default: false,
+    },
+    // Table header (for MeasureAndStreamTable compatibility)
+    tableHeader: {
+      type: Array,
+      default: () => [],
+    },
+    // Show pagination
+    showPagination: {
+      type: Boolean,
+      default: true,
+    },
+    // Stripe style
+    stripe: {
+      type: Boolean,
+      default: true,
+    },
+    // Border style
+    border: {
+      type: Boolean,
+      default: true,
+    },
+    // Minimum height for table
+    minHeight: {
+      type: String,
+      default: '440px',
+    },
+  });
+
+  const currentPage = ref(1);
+
+  // Compute paginated data
+  const paginatedData = computed(() => {
+    if (!props.showPagination) {
+      return props.data;
+    }
+    const start = (currentPage.value - 1) * props.pageSize;
+    const end = start + props.pageSize;
+    return props.data.slice(start, end);
+  });
+
+  // Compute effective columns (use columns or tableHeader)
+  const effectiveColumns = computed(() => {
+    return props.columns.length > 0 ? props.columns : props.tableHeader;
+  });
+
+  // Handle page change
+  function handlePageChange(page) {
+    currentPage.value = page;
+  }
+
+  // Reset to first page when data changes
+  watch(
+    () => props.data,
+    () => {
+      currentPage.value = 1;
+    },
+  );
+</script>
+
+<template>
+  <div class="topn-table">
+    <el-table
+      v-loading="loading"
+      element-loading-text="loading"
+      element-loading-spinner="el-icon-loading"
+      element-loading-background="rgba(0, 0, 0, 0.8)"
+      :data="paginatedData"
+      :style="{ width: '100%', margin: '10px 0', minHeight: minHeight }"
+      :stripe="stripe"
+      :border="border"
+      highlight-current-row
+      tooltip-effect="dark"
+      :empty-text="emptyText"
+    >
+      <el-table-column v-if="showSelection" type="selection" width="55" />
+      <el-table-column v-if="showIndex" type="index" label="number" width="90" 
/>
+      <el-table-column v-if="showTimestamp" label="timestamp" width="260" 
key="timestamp" prop="timestamp" />
+
+      <!-- Dynamic columns -->
+      <el-table-column
+        v-for="column in effectiveColumns"
+        :key="column.name || column.prop"
+        :label="column.label"
+        :prop="column.name || column.prop"
+        :width="column.width"
+        :sortable="column.sortable !== false"
+        show-overflow-tooltip
+      >
+        <template #default="scope">
+          <!-- Handle array types with popover -->
+          <el-popover
+            v-if="
+              (column.type || column.fieldType)?.includes('ARRAY') && 
scope.row[column.name || column.prop] !== 'Null'
+            "
+            effect="dark"
+            trigger="hover"
+            placement="top"
+            width="auto"
+          >
+            <template #default>
+              <div>{{ scope.row[column.name || column.prop].join('; ') }}</div>
+            </template>
+            <template #reference>
+              <el-tag>View</el-tag>
+            </template>
+          </el-popover>
+          <!-- Regular display -->
+          <div v-else>{{ scope.row[column.name || column.prop] }}</div>
+        </template>
+      </el-table-column>
+
+      <!-- Slot for custom columns -->
+      <slot name="columns" />
+    </el-table>
+
+    <!-- Pagination -->
+    <el-pagination
+      v-if="showPagination && data.length > 0"
+      background
+      layout="prev, pager, next"
+      :page-size="pageSize"
+      :total="data.length"
+      :current-page="currentPage"
+      @current-change="handlePageChange"
+    />
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .topn-table {
+    width: 100%;
+  }
+
+  .el-pagination {
+    margin-top: 10px;
+  }
+</style>
diff --git a/ui/src/components/common/TraceTable.vue 
b/ui/src/components/common/TraceTable.vue
new file mode 100644
index 00000000..df763416
--- /dev/null
+++ b/ui/src/components/common/TraceTable.vue
@@ -0,0 +1,168 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+
+<script setup>
+  const props = defineProps({
+    // Full data array
+    data: {
+      type: Array,
+      default: () => [],
+    },
+    // Span tags to display as columns
+    spanTags: {
+      type: Array,
+      default: () => [],
+    },
+    // Loading state
+    loading: {
+      type: Boolean,
+      default: false,
+    },
+    // Show selection column
+    showSelection: {
+      type: Boolean,
+      default: true,
+    },
+    // Border style
+    border: {
+      type: Boolean,
+      default: true,
+    },
+    // Enable traceId cell merging
+    enableMerge: {
+      type: Boolean,
+      default: true,
+    },
+    // Empty text
+    emptyText: {
+      type: String,
+      default: 'No trace data found',
+    },
+  });
+
+  const emit = defineEmits(['selection-change']);
+
+  // Extract tag value with proper formatting
+  const getTagValue = (data) => {
+    let value = data.value;
+
+    const isNullish = (val) => val === null || val === undefined || val === 
'null';
+    if (isNullish(value)) {
+      return 'N/A';
+    }
+    for (let i = 0; i < 2; i++) {
+      if (typeof value !== 'object') {
+        const strValue = value.toString();
+        return strValue.length > 100 ? strValue.substring(0, 100) + '...' : 
strValue;
+      }
+      for (const key in value) {
+        if (Object.hasOwn(value, key)) {
+          value = value[key];
+          break;
+        }
+      }
+      if (isNullish(value)) {
+        return 'N/A';
+      }
+    }
+
+    const strValue = value.toString();
+    return strValue.length > 100 ? strValue.substring(0, 100) + '...' : 
strValue;
+  };
+
+  // Cell merging strategy for traceId
+  const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
+    if (!props.enableMerge) {
+      return;
+    }
+
+    // Only merge the traceId column (first column after selection)
+    const traceIdColumnIndex = props.showSelection ? 1 : 0;
+    if (columnIndex === traceIdColumnIndex) {
+      const currentTraceId = row.traceId;
+      // Check if this is the first row with this traceId
+      if (rowIndex === 0 || props.data[rowIndex - 1].traceId !== 
currentTraceId) {
+        // Count how many rows have the same traceId
+        let rowspan = 1;
+        for (let i = rowIndex + 1; i < props.data.length; i++) {
+          if (props.data[i].traceId === currentTraceId) {
+            rowspan++;
+          } else {
+            break;
+          }
+        }
+        return {
+          rowspan: rowspan,
+          colspan: 1,
+        };
+      } else {
+        // This row's traceId is merged with a previous row
+        return {
+          rowspan: 0,
+          colspan: 0,
+        };
+      }
+    }
+  };
+  // Handle selection change
+  function handleSelectionChange(selection) {
+    emit('selection-change', selection);
+  }
+</script>
+
+<template>
+  <div class="trace-table">
+    <el-table
+      v-loading="loading"
+      element-loading-text="loading"
+      element-loading-spinner="el-icon-loading"
+      element-loading-background="rgba(0, 0, 0, 0.8)"
+      :data="data"
+      :border="border"
+      style="width: 100%"
+      @selection-change="handleSelectionChange"
+      :span-method="objectSpanMethod"
+      :empty-text="emptyText"
+    >
+      <el-table-column v-if="showSelection" type="selection" width="55" fixed 
/>
+      <el-table-column label="traceId" prop="traceId" width="200" fixed>
+        <template #default="scope">
+          {{ getTagValue({ value: scope.row.traceId }) }}
+        </template>
+      </el-table-column>
+      <el-table-column label="spanId" prop="spanId" width="300" fixed>
+        <template #default="scope">
+          {{ getTagValue({ value: scope.row.spanId }) }}
+        </template>
+      </el-table-column>
+      <el-table-column v-for="tag in spanTags" :key="tag" :label="tag" 
:prop="tag" min-width="200">
+        <template #default="scope">
+          {{ getTagValue({ value: scope.row[tag] }) }}
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .trace-table {
+    width: 100%;
+    overflow-x: auto;
+  }
+</style>
diff --git a/ui/src/components/common/data.js b/ui/src/components/common/data.js
index 71357bd0..55e6d500 100644
--- a/ui/src/components/common/data.js
+++ b/ui/src/components/common/data.js
@@ -72,6 +72,7 @@ export const CatalogToGroupType = {
   CATALOG_STREAM: 'stream',
   CATALOG_PROPERTY: 'property',
   CATALOG_TRACE: 'trace',
+  CATALOG_TOPN: 'topn',
 };
 
 // group type to catalog
@@ -80,6 +81,7 @@ export const GroupTypeToCatalog = {
   stream: 'CATALOG_STREAM',
   property: 'CATALOG_PROPERTY',
   trace: 'CATALOG_TRACE',
+  topn: 'CATALOG_TOPN',
 };
 
 export const TypeMap = {
diff --git a/ui/src/main.js b/ui/src/main.js
index dc0dea61..2932bf42 100644
--- a/ui/src/main.js
+++ b/ui/src/main.js
@@ -76,7 +76,7 @@ app.config.globalProperties.$loadingClose = () => {
 app.config.globalProperties.$message = ElMessage;
 app.config.globalProperties.$message.error = (status, text) => {
   ElMessage({
-    message: status + statusText,
+    message: `${status} ${text}`,
     type: 'error',
   });
 };
@@ -86,12 +86,6 @@ app.config.globalProperties.$message.errorNet = () => {
     type: 'error',
   });
 };
-app.config.globalProperties.$message.success = () => {
-  ElMessage({
-    message: 'OK',
-    type: 'success',
-  });
-};
 app.config.globalProperties.mittBus = new mitt();
 app.use(createPinia());
 app.use(router);
diff --git a/ui/src/router/index.js b/ui/src/router/index.js
index 0a5a92a3..2cf7c368 100644
--- a/ui/src/router/index.js
+++ b/ui/src/router/index.js
@@ -19,6 +19,7 @@
 
 import { createRouter, createWebHistory } from 'vue-router';
 import Header from '@/components/Header/index.vue';
+import { MENU_DEFAULT_PATH } from '@/components/Header/components/constants';
 
 const router = createRouter({
   history: createWebHistory(import.meta.env.BASE_URL),
@@ -31,7 +32,7 @@ const router = createRouter({
       path: '/banyandb',
       component: Header,
       name: 'banyandb',
-      redirect: '/banyandb/dashboard',
+      redirect: MENU_DEFAULT_PATH,
       meta: {
         keepAlive: false,
       },
@@ -270,6 +271,11 @@ const router = createRouter({
             },
           ],
         },
+        {
+          path: '/banyandb/query',
+          name: 'query',
+          component: () => import('@/views/Query/BydbQL.vue'),
+        },
       ],
     },
     {
diff --git a/ui/src/views/Query/BydbQL.vue b/ui/src/views/Query/BydbQL.vue
new file mode 100644
index 00000000..3bdf5f73
--- /dev/null
+++ b/ui/src/views/Query/BydbQL.vue
@@ -0,0 +1,140 @@
+<!--
+  ~ Licensed to Apache Software Foundation (ASF) under one or more contributor
+  ~ license agreements. See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright
+  ~ ownership. Apache Software Foundation (ASF) licenses this file to you under
+  ~ the Apache License, Version 2.0 (the "License"); you may
+  ~ not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing,
+  ~ software distributed under the License is distributed on an
+  ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~ KIND, either express or implied.  See the License for the
+  ~ specific language governing permissions and limitations
+  ~ under the License.
+-->
+<script setup>
+  import { ref } from 'vue';
+  import { Plus } from '@element-plus/icons-vue';
+  import { ElMessage } from 'element-plus';
+  import BydbQLQuery from '@/components/BydbQL/Index.vue';
+
+  let tabIndex = 2;
+  const activeTab = ref('tab-1');
+  const tabs = ref([
+    {
+      name: 'tab-1',
+      title: 'Query 1',
+      closable: false,
+    },
+  ]);
+
+  const addTab = () => {
+    const newTabName = `tab-${tabIndex++}`;
+    tabs.value.push({
+      name: newTabName,
+      title: `Query ${tabIndex - 1}`,
+      closable: true,
+    });
+    activeTab.value = newTabName;
+  };
+
+  const removeTab = (targetName) => {
+    if (tabs.value.length === 1) {
+      ElMessage.warning('At least one tab must remain');
+      return;
+    }
+
+    const targetIndex = tabs.value.findIndex((tab) => tab.name === targetName);
+    const targetTab = tabs.value[targetIndex];
+
+    if (!targetTab.closable) {
+      ElMessage.warning('This tab cannot be closed');
+      return;
+    }
+
+    tabs.value.splice(targetIndex, 1);
+
+    if (activeTab.value === targetName) {
+      const newActiveTab = tabs.value[targetIndex] || tabs.value[targetIndex - 
1];
+      activeTab.value = newActiveTab.name;
+    }
+  };
+</script>
+
+<template>
+  <div class="query-page">
+    <div class="tabs-header">
+      <el-tabs v-model="activeTab" class="query-tabs" closable 
@tab-remove="removeTab">
+        <el-tab-pane v-for="tab in tabs" :key="tab.name" :label="tab.title" 
:name="tab.name" :closable="tab.closable">
+          <BydbQLQuery />
+        </el-tab-pane>
+      </el-tabs>
+      <el-button
+        :icon="Plus"
+        size="small"
+        type="primary"
+        circle
+        @click="addTab"
+        class="add-tab-button"
+        title="Add new query tab"
+      />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+  .query-page {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    padding: 10px 20px;
+  }
+
+  .tabs-header {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+
+    :deep(.el-tabs__header) {
+      margin-bottom: 0;
+    }
+
+    .add-tab-button {
+      position: absolute;
+      top: 5px;
+      right: 10px;
+      z-index: 10;
+      width: 28px;
+      height: 28px;
+      padding: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+
+  .query-tabs {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+
+    :deep(.el-tabs__content) {
+      flex: 1;
+      overflow: auto;
+    }
+
+    :deep(.el-tab-pane) {
+      height: 100%;
+      padding-top: 10px;
+    }
+
+    :deep(.el-tabs__header) {
+      padding-right: 50px;
+    }
+  }
+</style>


Reply via email to