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 d1c05cc8 feat(UI): implement Trace Tree for debug mode (#823)
d1c05cc8 is described below

commit d1c05cc89b8083c59ecb2f9594d444e1c45be123
Author: Fine0830 <[email protected]>
AuthorDate: Sun Oct 26 19:31:26 2025 +0800

    feat(UI): implement Trace Tree for debug mode (#823)
---
 CHANGES.md                                         |   1 +
 ui/src/components/GroupTree/data.js                |  29 +--
 ui/src/components/Property/PropertyRead.vue        |  34 ++-
 ui/src/components/Read/index.vue                   |  57 +++--
 ui/src/components/TopNAggregation/index.vue        |  26 ++-
 ui/src/components/Trace/TraceRead.vue              |  26 ++-
 ui/src/components/TraceTree/MinTimeline.vue        |  95 ++++++++
 ui/src/components/TraceTree/MinTimelineMarker.vue  |  55 +++++
 ui/src/components/TraceTree/MinTimelineOverlay.vue | 140 ++++++++++++
 .../components/TraceTree/MinTimelineSelector.vue   | 153 +++++++++++++
 ui/src/components/TraceTree/SpanNode.vue           |  91 ++++++++
 ui/src/components/TraceTree/Table/Index.vue        |  58 +++++
 .../components/TraceTree/Table/TableContainer.vue  | 111 +++++++++
 ui/src/components/TraceTree/Table/TableItem.vue    | 248 +++++++++++++++++++++
 ui/src/components/TraceTree/Table/data.js          |  47 ++++
 ui/src/components/TraceTree/Table/table.scss       |  47 ++++
 ui/src/components/TraceTree/TraceContent.vue       | 142 ++++++++++++
 ui/src/components/TraceTree/useHooks.js            | 111 +++++++++
 ui/src/components/common/data.js                   |  29 +++
 ui/src/styles/custom.scss                          |   9 +
 ui/src/utils/debounce.js                           |  29 +++
 ui/src/utils/mutation.js                           |  44 ++++
 ui/src/views/Measure/index.vue                     |   2 +-
 ui/src/views/Property/index.vue                    |   2 +-
 ui/src/views/Stream/index.vue                      |   2 +-
 ui/src/views/Trace/index.vue                       |   2 +-
 26 files changed, 1534 insertions(+), 56 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 65ed2863..e696d090 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -50,6 +50,7 @@ Release Notes.
 - Implement cluster mode for trace.
 - Implement Trace views.
 - Use Fetch request to instead of axios request and remove axios.
+- Implement Trace Tree for debug mode.
 
 ### Bug Fixes
 
diff --git a/ui/src/components/GroupTree/data.js 
b/ui/src/components/GroupTree/data.js
index 7b336cc5..915b51c4 100644
--- a/ui/src/components/GroupTree/data.js
+++ b/ui/src/components/GroupTree/data.js
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import { CatalogToGroupType, GroupTypeToCatalog, TypeMap, 
SupportedIndexRuleTypes } from '../common/data.js';
+
 export const StageFields = [
   { label: 'Name', key: 'name' },
   { label: 'Shard number', key: 'shardNum' },
@@ -105,30 +107,5 @@ export const TargetTypes = {
   Group: 'group',
   Resources: 'resources',
 };
-// catalog to group type
-export const CatalogToGroupType = {
-  CATALOG_MEASURE: 'measure',
-  CATALOG_STREAM: 'stream',
-  CATALOG_PROPERTY: 'property',
-  CATALOG_TRACE: 'trace',
-};
-
-// group type to catalog
-export const GroupTypeToCatalog = {
-  measure: 'CATALOG_MEASURE',
-  stream: 'CATALOG_STREAM',
-  property: 'CATALOG_PROPERTY',
-  trace: 'CATALOG_TRACE',
-};
 
-export const TypeMap = {
-  topNAggregation: 'topn-agg',
-  indexRule: 'index-rule',
-  indexRuleBinding: 'index-rule-binding',
-  children: 'children',
-};
-export const SupportedIndexRuleTypes = [
-  CatalogToGroupType.CATALOG_STREAM,
-  CatalogToGroupType.CATALOG_MEASURE,
-  CatalogToGroupType.CATALOG_TRACE,
-];
+export { CatalogToGroupType, GroupTypeToCatalog, TypeMap, 
SupportedIndexRuleTypes };
diff --git a/ui/src/components/Property/PropertyRead.vue 
b/ui/src/components/Property/PropertyRead.vue
index 309f7cf0..2eb567e5 100644
--- a/ui/src/components/Property/PropertyRead.vue
+++ b/ui/src/components/Property/PropertyRead.vue
@@ -21,13 +21,14 @@
   import { useRoute } from 'vue-router';
   import { ElMessage } from 'element-plus';
   import { reactive, ref, watch, onMounted, getCurrentInstance } from 'vue';
-  import { RefreshRight, Search } from '@element-plus/icons-vue';
+  import { RefreshRight, Search, TrendCharts } from '@element-plus/icons-vue';
   import { fetchProperties, deleteProperty } 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';
 
   const { proxy } = getCurrentInstance();
   // Loading
@@ -44,6 +45,8 @@
   });
   const yamlCode = ref(`name: ${data.name}
 limit: 10`);
+  const showTracesDialog = ref(false);
+  const traceData = ref(null);
   const getProperties = async (params) => {
     $loadingCreate();
     const res = await fetchProperties({ groups: [data.group], name: data.name, 
limit: 10, ...params });
@@ -55,6 +58,7 @@ limit: 10`);
       });
       return;
     }
+    traceData.value = res.trace;
     data.tableData = (res.properties || []).map((item) => {
       item.tags.forEach((tag) => {
         tag.value = JSON.stringify(tag.value);
@@ -141,10 +145,8 @@ limit: 10`;
         <FormHeader :fields="data" />
       </template>
       <div class="button-group-operator">
-        <div>
-          <el-button size="small" :icon="Search" @click="searchProperties" 
plain />
-          <el-button size="small" :icon="RefreshRight" @click="getProperties" 
plain />
-        </div>
+        <el-button size="small" :icon="Search" @click="searchProperties" plain 
/>
+        <el-button size="small" :icon="RefreshRight" @click="getProperties" 
plain />
       </div>
       <CodeMirror ref="yamlRef" v-model="yamlCode" mode="yaml" style="height: 
200px" :lint="true" />
       <el-table :data="data.tableData" style="width: 100%; margin-top: 20px" 
border>
@@ -189,10 +191,30 @@ limit: 10`;
           </template>
         </el-table-column>
       </el-table>
+      <el-button
+        :icon="TrendCharts"
+        @click="showTracesDialog = true"
+        :disabled="!traceData"
+        plain
+        style="margin-top: 20px"
+      >
+        <span>Debug Trace</span>
+      </el-button>
     </el-card>
     <PropertyEditor ref="propertyEditorRef"></PropertyEditor>
     <PropertyValueReader ref="propertyValueViewerRef"></PropertyValueReader>
   </div>
+  <el-dialog
+    v-model="showTracesDialog"
+    width="90%"
+    :destroy-on-close="true"
+    @closed="showTracesDialog = false"
+    class="trace-dialog"
+  >
+    <div style="max-height: 74vh; overflow-y: auto">
+      <TraceTree :trace="traceData" />
+    </div>
+  </el-dialog>
 </template>
 <style lang="scss" scoped>
   :deep(.el-card) {
@@ -202,7 +224,7 @@ limit: 10`;
   .button-group-operator {
     display: flex;
     flex-direction: row;
-    justify-content: space-between;
+    justify-content: end;
     margin-bottom: 10px;
   }
 </style>
diff --git a/ui/src/components/Read/index.vue b/ui/src/components/Read/index.vue
index f6231c14..a2897485 100644
--- a/ui/src/components/Read/index.vue
+++ b/ui/src/components/Read/index.vue
@@ -16,22 +16,21 @@
   ~ specific language governing permissions and limitations
   ~ under the License.
 -->
-
 <script setup>
   import { reactive, ref, watch, getCurrentInstance, computed } from 'vue';
   import { useRoute } from 'vue-router';
   import { ElMessage } from 'element-plus';
-  import { Search, RefreshRight } from '@element-plus/icons-vue';
+  import { Search, RefreshRight, TrendCharts } from '@element-plus/icons-vue';
   import { getResourceOfAllType, getTableList } from '@/api/index';
   import { jsonToYaml, yamlToJson } from '@/utils/yaml';
   import CodeMirror from '@/components/CodeMirror/index.vue';
   import FormHeader from '../common/FormHeader.vue';
   import { Shortcuts, Last15Minutes } from '../common/data';
+  import { CatalogToGroupType } from '../GroupTree/data';
+  import TraceTree from '../TraceTree/TraceContent.vue';
 
   const route = useRoute();
-
   const yamlRef = ref();
-
   // Loading
   const { proxy } = getCurrentInstance();
   const $loadingCreate = 
getCurrentInstance().appContext.config.globalProperties.$loadingCreate;
@@ -86,6 +85,8 @@
     codeStorage: [],
     byStages: false,
   });
+  const showTracesDialog = ref(false);
+  const traceData = ref(null);
   const tableHeader = computed(() => {
     return data.tableTags.concat(data.tableFields);
   });
@@ -190,12 +191,15 @@ orderBy:
     data.fields = response[data.type].fields ? response[data.type].fields : [];
     handleCodeData();
   }
+  async function handleTracesData(trace) {
+    traceData.value = trace;
+  }
   async function getTableData() {
     data.tableData = [];
     data.loading = true;
     setTableFilterConfig();
     let paramList = JSON.parse(JSON.stringify(filterConfig));
-    if (data.type === 'measure') {
+    if (data.type === CatalogToGroupType.CATALOG_MEASURE) {
       paramList.tagProjection = paramList.projection;
       if (data.handleFields.length > 0) {
         paramList.fieldProjection = {
@@ -215,7 +219,8 @@ orderBy:
       });
       return;
     }
-    if (data.type === 'stream') {
+    handleTracesData(res.trace);
+    if (data.type === CatalogToGroupType.CATALOG_STREAM) {
       setTableData(res.elements);
     } else {
       setTableData(res.dataPoints);
@@ -235,7 +240,7 @@ orderBy:
           dataItem[tag.key] = tag.value[tagType[type]]?.value || 
tag.value[tagType[type]];
         }
       }
-      if (data.type === 'measure' && tableFields.length > 0) {
+      if (data.type === CatalogToGroupType.CATALOG_MEASURE && 
tableFields.length > 0) {
         item.fields.forEach((field) => {
           const name = field.name;
           const fieldType =
@@ -361,11 +366,10 @@ orderBy:
               placeholder="Please select"
               style="width: 200px"
             >
-              <el-option v-for="item in data.options" :key="item.value" 
:label="item.label" :value="item.value">
-              </el-option>
+              <el-option v-for="item in data.options" :key="item.value" 
:label="item.label" :value="item.value" />
             </el-select>
             <el-select
-              v-if="data.type === 'measure'"
+              v-if="data.type === CatalogToGroupType.CATALOG_MEASURE"
               v-model="data.handleFields"
               collapse-tags
               style="margin: 0 0 0 10px; flex: 0 0 300px"
@@ -374,8 +378,7 @@ orderBy:
               multiple
               placeholder="Please select Fields"
             >
-              <el-option v-for="item in data.fields" :key="item.name" 
:label="item.name" :value="item.name">
-              </el-option>
+              <el-option v-for="item in data.fields" :key="item.name" 
:label="item.name" :value="item.name" />
             </el-select>
             <el-date-picker
               @change="changeDatePicker"
@@ -387,8 +390,7 @@ orderBy:
               start-placeholder="begin"
               end-placeholder="end"
               :align="`right`"
-            >
-            </el-date-picker>
+            />
             <el-checkbox
               v-model="data.byStages"
               @change="setCode"
@@ -396,17 +398,16 @@ orderBy:
               size="large"
               style="margin-right: 10px"
             />
-            <el-button :icon="Search" @click="searchTableData" style="flex: 0 
0 auto" color="#6E38F7" plain></el-button>
+            <el-button :icon="Search" @click="searchTableData" style="flex: 0 
0 auto" color="#6E38F7" plain />
           </div>
         </el-col>
         <el-col :span="8">
           <div class="flex align-item-center justify-end" style="height: 30px">
-            <el-button :icon="RefreshRight" @click="getTableData" 
plain></el-button>
+            <el-button :icon="RefreshRight" @click="getTableData" plain />
           </div>
         </el-col>
       </el-row>
-      <CodeMirror ref="yamlRef" v-model="data.code" mode="yaml" style="height: 
200px" :lint="true" :readonly="false">
-      </CodeMirror>
+      <CodeMirror ref="yamlRef" v-model="data.code" mode="yaml" style="height: 
200px" :lint="true" :readonly="false" />
     </el-card>
     <el-card shadow="always">
       <el-table
@@ -452,8 +453,28 @@ orderBy:
           </template>
         </el-table-column>
       </el-table>
+      <el-button
+        :icon="TrendCharts"
+        :disabled="!traceData"
+        @click="showTracesDialog = true"
+        plain
+        style="margin-top: 20px"
+      >
+        <span>Debug Trace</span>
+      </el-button>
     </el-card>
   </div>
+  <el-dialog
+    v-model="showTracesDialog"
+    width="90%"
+    :destroy-on-close="true"
+    @closed="showTracesDialog = false"
+    class="trace-dialog"
+  >
+    <div style="max-height: 74vh; overflow-y: auto">
+      <TraceTree :trace="traceData" />
+    </div>
+  </el-dialog>
 </template>
 
 <style lang="scss" scoped>
diff --git a/ui/src/components/TopNAggregation/index.vue 
b/ui/src/components/TopNAggregation/index.vue
index fdf6c5ae..b54537e1 100644
--- a/ui/src/components/TopNAggregation/index.vue
+++ b/ui/src/components/TopNAggregation/index.vue
@@ -22,11 +22,12 @@
   import { useRoute } from 'vue-router';
   import { ElMessage } from 'element-plus';
   import { jsonToYaml, yamlToJson } from '@/utils/yaml';
-  import { Search, RefreshRight } from '@element-plus/icons-vue';
+  import { Search, RefreshRight, TrendCharts } from '@element-plus/icons-vue';
   import { getTopNAggregationData } from '@/api/index';
   import CodeMirror from '@/components/CodeMirror/index.vue';
   import FormHeader from '../common/FormHeader.vue';
   import { Shortcuts, Last15Minutes } from '../common/data';
+  import TraceTree from '../TraceTree/TraceContent.vue';
 
   const pageSize = 10;
   const route = useRoute();
@@ -42,6 +43,8 @@
   const yamlCode = ref('');
   const loading = ref(false);
   const currentList = ref([]);
+  const showTracesDialog = ref(false);
+  const traceData = ref(null);
 
   function initTopNAggregationData() {
     if (!(data.type && data.group && data.name)) {
@@ -79,6 +82,7 @@ fieldValueSort: 1`;
       });
       return;
     }
+    traceData.value = result.traces || null;
     data.lists = (result.lists || [])
       .map((d) => d.items.map((item) => ({ label: 
item.entity[0].value.str.value, value: item.value.int.value })))
       .flat();
@@ -188,8 +192,28 @@ fieldValueSort: 1`;
         @prev-click="changePage"
         @next-click="changePage"
       />
+      <el-button
+        :icon="TrendCharts"
+        @click="showTracesDialog = true"
+        :disabled="!traceData"
+        plain
+        :style="{ marginTop: '20px' }"
+      >
+        <span>Debug Trace</span>
+      </el-button>
     </el-card>
   </div>
+  <el-dialog
+    v-model="showTracesDialog"
+    width="90%"
+    :destroy-on-close="true"
+    @closed="showTracesDialog = false"
+    class="trace-dialog"
+  >
+    <div style="max-height: 74vh; overflow-y: auto">
+      <TraceTree :trace="traceData" />
+    </div>
+  </el-dialog>
 </template>
 
 <style lang="scss" scoped>
diff --git a/ui/src/components/Trace/TraceRead.vue 
b/ui/src/components/Trace/TraceRead.vue
index 2d342672..04f65396 100644
--- a/ui/src/components/Trace/TraceRead.vue
+++ b/ui/src/components/Trace/TraceRead.vue
@@ -23,12 +23,13 @@
   import { useRoute } from 'vue-router';
   import { ElMessage } from 'element-plus';
   import { reactive, ref, watch } from 'vue';
-  import { RefreshRight, Search, Download } from '@element-plus/icons-vue';
+  import { RefreshRight, Search, Download, TrendCharts } from 
'@element-plus/icons-vue';
   import { jsonToYaml, yamlToJson } from '@/utils/yaml';
   import CodeMirror from '@/components/CodeMirror/index.vue';
   import FormHeader from '../common/FormHeader.vue';
   import { Last15Minutes, Shortcuts } from '../common/data';
   import JSZip from 'jszip';
+  import TraceTree from '../TraceTree/TraceContent.vue';
 
   const { proxy } = getCurrentInstance();
   const route = useRoute();
@@ -45,6 +46,8 @@
   });
   const yamlCode = ref(``);
   const selectedSpans = ref([]);
+  const showTracesDialog = ref(false);
+  const traceData = ref(null);
 
   const getTraces = async (params) => {
     if (!data.indexRule?.metadata?.name) {
@@ -65,6 +68,7 @@
       });
       return;
     }
+    traceData.value = response.traceQueryResult;
     data.spanTags = [];
     data.tableData = (response.traces || [])
       .map((trace) => {
@@ -383,11 +387,31 @@ orderBy:
               </template>
             </el-table-column>
           </el-table>
+          <el-button
+            :icon="TrendCharts"
+            @click="showTracesDialog = true"
+            :disabled="!traceData"
+            plain
+            :style="{ marginTop: '20px' }"
+          >
+            <span>Debug Trace</span>
+          </el-button>
         </div>
       </div>
       <el-empty v-else description="No trace data found" style="margin-top: 
20px" />
     </el-card>
   </div>
+  <el-dialog
+    v-model="showTracesDialog"
+    width="90%"
+    :destroy-on-close="true"
+    @closed="showTracesDialog = false"
+    class="trace-dialog"
+  >
+    <div style="max-height: 74vh; overflow-y: auto">
+      <TraceTree :trace="traceData" />
+    </div>
+  </el-dialog>
 </template>
 <style lang="scss" scoped>
   :deep(.el-card) {
diff --git a/ui/src/components/TraceTree/MinTimeline.vue 
b/ui/src/components/TraceTree/MinTimeline.vue
new file mode 100644
index 00000000..7d10504c
--- /dev/null
+++ b/ui/src/components/TraceTree/MinTimeline.vue
@@ -0,0 +1,95 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <div class="trace-min-timeline">
+    <div class="timeline-marker-fixed">
+      <svg width="100%" height="20px">
+        <MinTimelineMarker :minTimestamp="minTimestamp" 
:maxTimestamp="maxTimestamp" :lineHeight="20" />
+      </svg>
+    </div>
+    <div class="timeline-content" :style="{ paddingRight: (spanList.length + 
1) * rowHeight < 200 ? '20px' : '14px' }">
+      <svg ref="svgEle" width="100%" :height="`${(spanList.length + 1) * 
rowHeight}px`">
+        <MinTimelineOverlay
+          :minTimestamp="minTimestamp"
+          :maxTimestamp="maxTimestamp"
+          @setSelectedMinTimestamp="setSelectedMinTimestamp"
+          @setSelectedMaxTimestamp="setSelectedMaxTimestamp"
+        />
+        <MinTimelineSelector
+          :minTimestamp="minTimestamp"
+          :maxTimestamp="maxTimestamp"
+          :selectedMinTimestamp="selectedMinTimestamp"
+          :selectedMaxTimestamp="selectedMaxTimestamp"
+          @setSelectedMinTimestamp="setSelectedMinTimestamp"
+          @setSelectedMaxTimestamp="setSelectedMaxTimestamp"
+        />
+        <g v-for="(item, index) in spanList" :key="index" 
:transform="`translate(0, ${(index + 1) * rowHeight + 3})`">
+          <SpanNode :span="item" :minTimestamp="minTimestamp" 
:maxTimestamp="maxTimestamp" :depth="index + 1" />
+        </g>
+      </svg>
+    </div>
+  </div>
+</template>
+<script setup>
+  import { ref } from 'vue';
+  import SpanNode from './SpanNode.vue';
+  import MinTimelineMarker from './MinTimelineMarker.vue';
+  import MinTimelineOverlay from './MinTimelineOverlay.vue';
+  import MinTimelineSelector from './MinTimelineSelector.vue';
+
+  const props = defineProps({
+    spanList: Array,
+    minTimestamp: Number,
+    maxTimestamp: Number,
+  });
+  const svgEle = ref(null);
+  const rowHeight = 12;
+
+  const selectedMinTimestamp = ref(props.minTimestamp);
+  const selectedMaxTimestamp = ref(props.maxTimestamp);
+  const emit = defineEmits(['updateSelectedMaxTimestamp', 
'updateSelectedMinTimestamp']);
+  const setSelectedMinTimestamp = (value) => {
+    selectedMinTimestamp.value = value;
+    emit('updateSelectedMinTimestamp', value);
+  };
+  const setSelectedMaxTimestamp = (value) => {
+    selectedMaxTimestamp.value = value;
+    emit('updateSelectedMaxTimestamp', value);
+  };
+</script>
+<style lang="scss" scoped>
+  .trace-min-timeline {
+    width: 100%;
+    max-height: 200px;
+    border-bottom: 1px solid var(--el-border-color-light);
+    display: flex;
+    flex-direction: column;
+  }
+
+  .timeline-marker-fixed {
+    width: 100%;
+    padding-right: 20px;
+    padding-top: 5px;
+    background: var(--el-bg-color);
+    border-bottom: 1px solid var(--el-border-color-light);
+    z-index: 1;
+  }
+
+  .timeline-content {
+    flex: 1;
+    width: 100%;
+    overflow: auto;
+  }
+</style>
diff --git a/ui/src/components/TraceTree/MinTimelineMarker.vue 
b/ui/src/components/TraceTree/MinTimelineMarker.vue
new file mode 100644
index 00000000..8a62bca1
--- /dev/null
+++ b/ui/src/components/TraceTree/MinTimelineMarker.vue
@@ -0,0 +1,55 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <g v-for="(marker, index) in markers" :key="marker.duration">
+    <line
+      :x1="`${marker.position}%`"
+      :y1="0"
+      :x2="`${marker.position}%`"
+      :y2="lineHeight ? `${lineHeight}` : '100%'"
+      stroke="var(--el-border-color-light)"
+    />
+    <text
+      :key="`label-${marker.duration}`"
+      :x="`${marker.position}%`"
+      :y="12"
+      font-size="10"
+      fill="#999"
+      text-anchor="right"
+      :transform="`translate(${index === markers.length - 1 ? -50 : 5}, 0)`"
+    >
+      {{ marker.duration }}ms
+    </text>
+  </g>
+</template>
+<script setup>
+  import { computed } from 'vue';
+
+  const props = defineProps({
+    minTimestamp: Number,
+    maxTimestamp: Number,
+    lineHeight: [Number, String],
+  });
+  const markers = computed(() => {
+    const maxDuration = props.maxTimestamp - props.minTimestamp;
+    const markerDurations = [0, (maxDuration * 1) / 3, (maxDuration * 2) / 3, 
maxDuration];
+
+    return markerDurations.map((duration) => ({
+      duration: duration.toFixed(2),
+      position: maxDuration > 0 ? (duration / maxDuration) * 100 : 0,
+    }));
+  });
+</script>
+<style scoped></style>
diff --git a/ui/src/components/TraceTree/MinTimelineOverlay.vue 
b/ui/src/components/TraceTree/MinTimelineOverlay.vue
new file mode 100644
index 00000000..11b8b272
--- /dev/null
+++ b/ui/src/components/TraceTree/MinTimelineOverlay.vue
@@ -0,0 +1,140 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <g>
+    <rect
+      v-if="mouseDownX !== undefined && currentX !== undefined"
+      :x="`${Math.min(mouseDownX, currentX)}%`"
+      y="0"
+      :width="`${Math.abs(mouseDownX - currentX) || 0}%`"
+      height="100%"
+      fill="var(--el-color-secondary-light-6)"
+      fill-opacity="0.2"
+      pointer-events="none"
+    />
+    <rect
+      ref="rootEl"
+      x="0"
+      y="0"
+      width="100%"
+      height="100%"
+      @mousedown="handleMouseDown"
+      @mousemove="handleMouseHoverMove"
+      @mouseleave="handleMouseHoverLeave"
+      fill-opacity="0"
+      cursor="col-resize"
+    />
+    <line
+      v-if="hoverX"
+      :x1="`${hoverX}%`"
+      :y1="0"
+      :x2="`${hoverX}%`"
+      y2="100%"
+      stroke="var(--el-color-secondary-light-6)"
+      stroke-width="1"
+      pointer-events="none"
+    />
+  </g>
+</template>
+<script setup>
+  import { onBeforeUnmount, ref } from 'vue';
+
+  const props = defineProps({
+    minTimestamp: Number,
+    maxTimestamp: Number,
+  });
+  const emit = defineEmits(['setSelectedMaxTimestamp', 
'setSelectedMinTimestamp']);
+  const rootEl = ref(null);
+  const mouseDownX = ref(undefined);
+  const currentX = ref(undefined);
+  const hoverX = ref(undefined);
+  const mouseDownXRef = ref(undefined);
+  const isDragging = ref(false);
+
+  function handleMouseMove(e) {
+    if (!rootEl.value) {
+      return;
+    }
+    const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
+    currentX.value = x || 0;
+  }
+  function handleMouseUp(e) {
+    if (!isDragging.value || !rootEl.value || mouseDownXRef.value === 
undefined) {
+      return;
+    }
+
+    const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
+    const adjustedX = Math.abs(x - mouseDownXRef.value) < 1 ? x + 1 : x;
+
+    const t1 = (mouseDownXRef.value / 100) * (props.maxTimestamp - 
props.minTimestamp) + props.minTimestamp;
+    const t2 = (adjustedX / 100) * (props.maxTimestamp - props.minTimestamp) + 
props.minTimestamp;
+    const newMinTimestmap = Math.min(t1, t2);
+    const newMaxTimestamp = Math.max(t1, t2);
+
+    emit('setSelectedMinTimestamp', newMinTimestmap);
+    emit('setSelectedMaxTimestamp', newMaxTimestamp);
+
+    currentX.value = undefined;
+    mouseDownX.value = undefined;
+    mouseDownXRef.value = undefined;
+    isDragging.value = false;
+
+    document.removeEventListener('mousemove', handleMouseMove);
+    document.removeEventListener('mouseup', handleMouseUp);
+  }
+
+  const calculateX = (parentRect, x) => {
+    const value = ((x - parentRect.left) / (parentRect.right - 
parentRect.left)) * 100;
+    if (value <= 0) {
+      return 0;
+    }
+    if (value >= 100) {
+      return 100;
+    }
+    return value;
+  };
+
+  function handleMouseDown(e) {
+    if (!rootEl.value) {
+      return;
+    }
+    const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX) || 0;
+    currentX.value = x;
+    mouseDownX.value = x;
+    mouseDownXRef.value = x;
+    isDragging.value = true;
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+  }
+
+  function handleMouseHoverMove(e) {
+    if (e.buttons !== 0 || !rootEl.value) {
+      return;
+    }
+    const x = calculateX(rootEl.value.getBoundingClientRect(), e.pageX);
+    hoverX.value = x;
+  }
+
+  function handleMouseHoverLeave() {
+    hoverX.value = undefined;
+  }
+
+  onBeforeUnmount(() => {
+    document.removeEventListener('mousemove', handleMouseMove);
+    document.removeEventListener('mouseup', handleMouseUp);
+    isDragging.value = false;
+  });
+</script>
diff --git a/ui/src/components/TraceTree/MinTimelineSelector.vue 
b/ui/src/components/TraceTree/MinTimelineSelector.vue
new file mode 100644
index 00000000..a1a8330d
--- /dev/null
+++ b/ui/src/components/TraceTree/MinTimelineSelector.vue
@@ -0,0 +1,153 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <g>
+    <rect
+      x="0"
+      y="0"
+      :width="`${boundaryLeft}%`"
+      height="100%"
+      fill="var(--el-color-primary-light-8)"
+      fill-opacity="0.6"
+      pointer-events="none"
+    />
+    <rect
+      :x="`${boundaryRight}%`"
+      y="0"
+      :width="`${100 - boundaryRight}%`"
+      height="100%"
+      fill="var(--el-color-primary-light-8)"
+      fill-opacity="0.6"
+      pointer-events="none"
+    />
+    <rect
+      :x="`${boundaryLeft}%`"
+      y="0"
+      width="3"
+      height="100%"
+      fill="var(--el-color-primary-light-5)"
+      transform="translate(-1)"
+      pointer-events="none"
+    />
+    <rect
+      :x="`${boundaryRight}%`"
+      y="0"
+      width="3"
+      height="100%"
+      fill="var(--el-color-primary-light-5)"
+      transform="translate(-1)"
+      pointer-events="none"
+    />
+
+    <rect
+      v-if="minMouseDownX !== undefined && minCurrentX !== undefined"
+      :x="`${Math.min(minMouseDownX, minCurrentX)}%`"
+      y="0"
+      :width="`${Math.abs(minMouseDownX - minCurrentX)}%`"
+      height="100%"
+      fill="var(--el-color-primary-light-6)"
+      fill-opacity="0.4"
+      pointer-events="none"
+    />
+    <rect
+      v-if="maxMouseDownX !== undefined && maxCurrentX !== undefined"
+      :x="`${Math.min(maxMouseDownX, maxCurrentX)}%`"
+      y="0"
+      :width="`${Math.abs(maxMouseDownX - maxCurrentX)}%`"
+      height="100%"
+      fill="var(--el-color-primary-light-6)"
+      fill-opacity="0.4"
+      pointer-events="none"
+    />
+    <rect
+      :x="`${boundaryLeft}%`"
+      y="0"
+      width="6"
+      height="40%"
+      fill="var(--el-color-primary)"
+      @mousedown="minRangeHandler.onMouseDown"
+      cursor="pointer"
+      transform="translate(-3)"
+    />
+    <rect
+      :x="`${boundaryRight}%`"
+      y="0"
+      width="6"
+      height="40%"
+      fill="var(--el-color-primary)"
+      @mousedown="maxRangeHandler.onMouseDown"
+      cursor="pointer"
+      transform="translate(-3)"
+    />
+  </g>
+</template>
+<script setup>
+  import { computed, ref, onMounted } from 'vue';
+  import { useRangeTimestampHandler, adjustPercentValue } from './useHooks.js';
+
+  const emit = defineEmits(['setSelectedMinTimestamp', 
'setSelectedMaxTimestamp']);
+  const props = defineProps({
+    minTimestamp: Number,
+    maxTimestamp: Number,
+    selectedMinTimestamp: Number,
+    selectedMaxTimestamp: Number,
+  });
+  const svgEle = ref(null);
+
+  onMounted(() => {
+    const element = document.querySelector('.trace-min-timeline svg');
+    if (element) {
+      svgEle.value = element;
+    }
+  });
+  const maxOpositeX = computed(
+    () => ((props.selectedMaxTimestamp - props.minTimestamp) / 
(props.maxTimestamp - props.minTimestamp)) * 100,
+  );
+  const minOpositeX = computed(
+    () => ((props.selectedMinTimestamp - props.minTimestamp) / 
(props.maxTimestamp - props.minTimestamp)) * 100,
+  );
+
+  const minRangeHandler = computed(() => {
+    return useRangeTimestampHandler({
+      rootEl: svgEle.value,
+      minTimestamp: props.minTimestamp,
+      maxTimestamp: props.maxTimestamp,
+      selectedTimestamp: props.selectedMaxTimestamp,
+      isSmallerThanOpositeX: true,
+      setTimestamp: (value) => emit('setSelectedMinTimestamp', value),
+    });
+  });
+  const maxRangeHandler = computed(() =>
+    useRangeTimestampHandler({
+      rootEl: svgEle.value,
+      minTimestamp: props.minTimestamp,
+      maxTimestamp: props.maxTimestamp,
+      selectedTimestamp: props.selectedMinTimestamp,
+      isSmallerThanOpositeX: false,
+      setTimestamp: (value) => emit('setSelectedMaxTimestamp', value),
+    }),
+  );
+
+  const boundaryLeft = computed(() => {
+    return adjustPercentValue(minOpositeX.value);
+  });
+
+  const boundaryRight = computed(() => adjustPercentValue(maxOpositeX.value) 
|| 0);
+
+  const minMouseDownX = computed(() => minRangeHandler.value.mouseDownX.value 
|| 0);
+  const minCurrentX = computed(() => minRangeHandler.value.currentX.value || 
0);
+  const maxMouseDownX = computed(() => maxRangeHandler.value.mouseDownX.value 
|| 0);
+  const maxCurrentX = computed(() => maxRangeHandler.value.currentX.value || 
0);
+</script>
diff --git a/ui/src/components/TraceTree/SpanNode.vue 
b/ui/src/components/TraceTree/SpanNode.vue
new file mode 100644
index 00000000..e4b73def
--- /dev/null
+++ b/ui/src/components/TraceTree/SpanNode.vue
@@ -0,0 +1,91 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <rect
+    :x="`${startPct}%`"
+    :y="0"
+    :width="`${widthPct}%`"
+    :height="barHeight"
+    fill="var(--el-color-primary)"
+    rx="2"
+    ry="2"
+  />
+</template>
+
+<script setup>
+  import { computed } from 'vue';
+
+  const props = defineProps({
+    span: Object,
+    minTimestamp: Number,
+    maxTimestamp: Number,
+    selectedMaxTimestamp: Number,
+    selectedMinTimestamp: Number,
+  });
+  const barHeight = 3;
+
+  const widthScale = computed(() => {
+    const { selectedMinTimestamp, selectedMaxTimestamp, minTimestamp, 
maxTimestamp } = props;
+    let max = maxTimestamp - minTimestamp;
+    if (selectedMaxTimestamp !== undefined && selectedMinTimestamp !== 
undefined) {
+      max = selectedMaxTimestamp - selectedMinTimestamp;
+    }
+    return (duration) => {
+      const d = Math.max(0, duration || 0);
+      return (d / max) * 100;
+    };
+  });
+  const startPct = computed(() => {
+    const { span, selectedMinTimestamp, minTimestamp } = props;
+    const end = span.endTime;
+    let start = span.startTime;
+    if (selectedMinTimestamp !== undefined) {
+      start = selectedMinTimestamp > start ? (end < selectedMinTimestamp ? 0 : 
selectedMinTimestamp) : start;
+    }
+    const dur = start - (selectedMinTimestamp || minTimestamp);
+
+    return Math.max(0, widthScale.value(dur));
+  });
+
+  const widthPct = computed(() => {
+    const { span, selectedMinTimestamp, selectedMaxTimestamp } = props;
+    let start = span.startTime;
+    let end = span.endTime;
+    if (selectedMinTimestamp !== undefined) {
+      start = selectedMinTimestamp > start ? selectedMinTimestamp : start;
+      if (end < selectedMinTimestamp) {
+        return 0;
+      }
+    }
+    if (selectedMaxTimestamp !== undefined) {
+      end = selectedMaxTimestamp < end ? selectedMaxTimestamp : end;
+      if (span.startTime > selectedMaxTimestamp) {
+        return 0;
+      }
+    }
+    const dur = end - start;
+    return Math.max(0, widthScale.value(dur));
+  });
+</script>
+
+<style lang="scss" scoped>
+  .span-label {
+    font-weight: 500;
+  }
+
+  .span-duration {
+    font-weight: 400;
+  }
+</style>
diff --git a/ui/src/components/TraceTree/Table/Index.vue 
b/ui/src/components/TraceTree/Table/Index.vue
new file mode 100644
index 00000000..72192571
--- /dev/null
+++ b/ui/src/components/TraceTree/Table/Index.vue
@@ -0,0 +1,58 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <div class="trace-table-charts">
+    <TableContainer
+      :tableData="segmentId"
+      :selectedMaxTimestamp="selectedMaxTimestamp"
+      :selectedMinTimestamp="selectedMinTimestamp"
+    >
+      <div class="trace-tips" v-if="!segmentId.length">No data</div>
+    </TableContainer>
+  </div>
+</template>
+<script setup>
+  import { ref, onMounted } from 'vue';
+  import TableContainer from '../Table/TableContainer.vue';
+
+  const props = defineProps({
+    data: Array,
+    selectedMaxTimestamp: Number,
+    selectedMinTimestamp: Number,
+  });
+  const segmentId = ref([]);
+  onMounted(() => {
+    segmentId.value = setLevel(props.data);
+  });
+
+  function setLevel(arr, level = 1, totalExec) {
+    for (const item of arr) {
+      item.level = level;
+      totalExec = totalExec || item.endTime - item.startTime;
+      item.totalExec = totalExec;
+      if (item.children && item.children.length > 0) {
+        setLevel(item.children, level + 1, totalExec);
+      }
+    }
+    return arr;
+  }
+</script>
+<style lang="scss" scoped>
+  .trace-table-charts {
+    overflow: auto;
+    padding: 10px;
+    height: 100%;
+    width: 100%;
+    position: relative;
+  }
+</style>
diff --git a/ui/src/components/TraceTree/Table/TableContainer.vue 
b/ui/src/components/TraceTree/Table/TableContainer.vue
new file mode 100644
index 00000000..04740875
--- /dev/null
+++ b/ui/src/components/TraceTree/Table/TableContainer.vue
@@ -0,0 +1,111 @@
+<!-- Licensed to the 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.
+The 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. -->
+
+<template>
+  <div class="trace-table">
+    <div class="trace-table-header">
+      <div class="method" :style="`width: ${method}px`">
+        <span class="dragger" ref="dragger">
+          <el-icon><ArrowLeft /></el-icon>
+          <el-icon><MoreFilled /></el-icon>
+          <el-icon><ArrowRight /></el-icon>
+        </span>
+        {{ TraceConstant[0].value }}
+      </div>
+      <div :class="item.label" v-for="(item, index) in TraceConstant.slice(1)" 
:key="index">
+        {{ item.value }}
+      </div>
+    </div>
+    <TableItem
+      :method="method"
+      v-for="(item, index) in tableData"
+      :data="item"
+      :key="`key${index}`"
+      :selectedMaxTimestamp="selectedMaxTimestamp"
+      :selectedMinTimestamp="selectedMinTimestamp"
+    />
+    <slot></slot>
+  </div>
+</template>
+<script setup>
+  import { ref, onMounted } from 'vue';
+  import TableItem from './TableItem.vue';
+  import { TraceConstant } from './data.js';
+  import { ArrowLeft, MoreFilled, ArrowRight } from '@element-plus/icons-vue';
+
+  const props = defineProps({
+    tableData: Array,
+    selectedMaxTimestamp: Number,
+    selectedMinTimestamp: Number,
+  });
+
+  const method = ref(460);
+  const dragger = ref(null);
+
+  onMounted(() => {
+    const drag = dragger.value;
+    if (!drag) {
+      return;
+    }
+    drag.onmousedown = (event) => {
+      event.stopPropagation();
+      const diffX = event.clientX;
+      const copy = method.value;
+      document.onmousemove = (documentEvent) => {
+        const moveX = documentEvent.clientX - diffX;
+        method.value = copy + moveX;
+      };
+      document.onmouseup = () => {
+        document.onmousemove = null;
+        document.onmouseup = null;
+      };
+    };
+  });
+</script>
+<style lang="scss" scoped>
+  @import url('./table.scss');
+
+  .trace-table {
+    font-size: 12px;
+    height: 100%;
+    overflow: auto;
+    width: 100%;
+  }
+
+  .dragger {
+    float: right;
+    cursor: move;
+  }
+
+  .trace-table-header {
+    white-space: nowrap;
+    user-select: none;
+    border-left: 0;
+    border-right: 0;
+    border-bottom: 1px solid var(--sw-trace-list-border);
+  }
+
+  .trace-table-header div {
+    display: inline-block;
+    background-color: var(--sw-table-header);
+    padding: 0 4px;
+    border: 1px solid transparent;
+    border-right: 1px dotted silver;
+    overflow: hidden;
+    line-height: 30px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+</style>
diff --git a/ui/src/components/TraceTree/Table/TableItem.vue 
b/ui/src/components/TraceTree/Table/TableItem.vue
new file mode 100644
index 00000000..455bf7ba
--- /dev/null
+++ b/ui/src/components/TraceTree/Table/TableItem.vue
@@ -0,0 +1,248 @@
+<!-- Licensed to the 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.
+The 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. -->
+
+<template>
+  <div>
+    <div
+      :class="[
+        'trace-item',
+        'level' + ((data.level || 0) - 1),
+        { 'trace-item-error': data.isError },
+        { highlighted: inTimeRange },
+      ]"
+    >
+      <div
+        :class="['method', 'level' + ((data.level || 0) - 1)]"
+        :style="{
+          'text-indent': ((data.level || 0) - 1) * 10 + 'px',
+          width: `${method}px`,
+        }"
+        @click.stop
+      >
+        <el-icon
+          :style="!displayChildren ? 'transform: rotate(-90deg);' : ''"
+          @click.stop="toggle"
+          v-if="data.children && data.children.length"
+        >
+          <ArrowDown />
+        </el-icon>
+        {{ data.message }}
+      </div>
+      <div class="start-time">
+        {{ new Date(data.startTime).toLocaleString() }}
+      </div>
+      <div class="exec-ms">
+        {{ (data.duration / 1000 / 1000).toFixed(3) }}
+      </div>
+      <div class="exec-percent">
+        {{ outterPercent }}
+      </div>
+      <div class="exec-percent">
+        {{ innerPercent }}
+      </div>
+      <div class="self">
+        {{ (data.selfDuration / 1000 / 1000).toFixed(3) }}
+      </div>
+      <div class="tags" @click.stop="showTagsDialog" :class="{ clickable: 
data.tags && data.tags.length > 0 }">
+        <div class="tag" v-for="(tag, index) in visibleTags" :key="index">
+          {{ tag.key }}: {{ tag.value && tag.value.length > 20 ? 
tag.value.slice(0, 20) + '...' : tag.value }}
+        </div>
+        <span v-if="hasMoreTags" class="more-tags">+{{ data.tags.length - 
MAX_VISIBLE_TAGS }}</span>
+      </div>
+    </div>
+
+    <el-dialog v-model="tagsDialogVisible" title="Tag Details" width="600px" 
:append-to-body="true">
+      <div class="tags-details" style="max-height: 70vh; overflow-y: auto">
+        <el-table :data="data.tags" style="width: 100%">
+          <el-table-column prop="key" label="Key" width="200" />
+          <el-table-column prop="value" label="Value">
+            <template #default="scope">
+              <div class="tag-value">{{ scope.row.value }}</div>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-dialog>
+    <div v-show="data.children && data.children.length > 0 && displayChildren" 
class="children-trace">
+      <table-item
+        v-for="(child, index) in data.children"
+        :method="method"
+        :key="index"
+        :data="child"
+        :selectedMaxTimestamp="selectedMaxTimestamp"
+        :selectedMinTimestamp="selectedMinTimestamp"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+  import { ref, computed } from 'vue';
+  import { ArrowDown } from '@element-plus/icons-vue';
+
+  const props = defineProps({
+    data: Object,
+    method: Number,
+    selectedMaxTimestamp: Number,
+    selectedMinTimestamp: Number,
+  });
+  const displayChildren = ref(true);
+  const tagsDialogVisible = ref(false);
+  const MAX_VISIBLE_TAGS = 1;
+
+  const outterPercent = computed(() => {
+    if (props.data.level === 1) {
+      return '100%';
+    }
+    const exec = props.data.endTime - props.data.startTime ? 
props.data.endTime - props.data.startTime : 0;
+    let result = (exec / props.data.totalExec) * 100;
+    result = result > 100 ? 100 : result;
+    const resultStr = result.toFixed(2) + '%';
+    return resultStr === '0.00%' ? '0.9%' : resultStr;
+  });
+  const innerPercent = computed(() => {
+    const result = (props.data.selfDuration / props.data.duration) * 100;
+    const resultStr = result.toFixed(2) + '%';
+    return resultStr === '0.00%' ? '0.9%' : resultStr;
+  });
+  const inTimeRange = computed(() => {
+    if (props.selectedMinTimestamp === undefined || props.selectedMaxTimestamp 
=== undefined) {
+      return true;
+    }
+
+    return props.data.startTime <= props.selectedMaxTimestamp && 
props.data.endTime >= props.selectedMinTimestamp;
+  });
+
+  const visibleTags = computed(() => {
+    if (!props.data.tags || props.data.tags.length === 0) {
+      return [];
+    }
+    return props.data.tags.slice(0, MAX_VISIBLE_TAGS);
+  });
+
+  const hasMoreTags = computed(() => {
+    return props.data.tags && props.data.tags.length > MAX_VISIBLE_TAGS;
+  });
+
+  function toggle() {
+    displayChildren.value = !displayChildren.value;
+  }
+
+  function showTagsDialog() {
+    if (props.data.tags && props.data.tags.length > 0) {
+      tagsDialogVisible.value = true;
+    }
+  }
+</script>
+<style lang="scss" scoped>
+  @import url('./table.scss');
+
+  .trace-item.level0 {
+    &:hover {
+      background: rgb(0 0 0 / 4%);
+    }
+  }
+
+  .highlighted {
+    color: var(--el-color-primary);
+  }
+
+  .trace-item-error {
+    color: #e54c17;
+  }
+
+  .trace-item {
+    white-space: nowrap;
+    position: relative;
+  }
+
+  .trace-item.selected {
+    background-color: var(--sw-list-selected);
+  }
+
+  .trace-item:not(.level0):hover {
+    background-color: var(--sw-list-hover);
+  }
+
+  .trace-item > div {
+    padding: 0 5px;
+    display: inline-block;
+    border: 1px solid transparent;
+    border-right: 1px dotted silver;
+    overflow: hidden;
+    line-height: 30px;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .trace-item > div.method {
+    padding-left: 10px;
+    cursor: pointer;
+  }
+
+  .trace-item div.exec-percent {
+    height: 30px;
+    padding: 0 8px;
+  }
+
+  .link-span {
+    text-decoration: underline;
+  }
+
+  .tags {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+
+    &.clickable {
+      cursor: pointer;
+
+      &:hover {
+        background-color: rgba(0, 0, 0, 0.05);
+      }
+    }
+
+    .tag {
+      display: inline-block;
+      padding: 2px 6px;
+      background-color: #f0f0f0;
+      border-radius: 3px;
+      font-size: 12px;
+    }
+
+    .more-tags {
+      cursor: pointer;
+      color: var(--el-color-primary);
+      font-weight: bold;
+      padding: 2px 6px;
+      font-size: 11px;
+
+      &:hover {
+        text-decoration: underline;
+      }
+    }
+  }
+
+  .tags-details {
+    :deep(.el-table) {
+      font-size: 13px;
+    }
+
+    .tag-value {
+      word-break: break-all;
+      white-space: pre-wrap;
+      padding: 4px 0;
+    }
+  }
+</style>
diff --git a/ui/src/components/TraceTree/Table/data.js 
b/ui/src/components/TraceTree/Table/data.js
new file mode 100644
index 00000000..2baae5a6
--- /dev/null
+++ b/ui/src/components/TraceTree/Table/data.js
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the 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.
+ * The 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 TraceConstant = [
+  {
+    label: 'method',
+    value: 'Method',
+  },
+  {
+    label: 'start-time',
+    value: 'Start Time',
+  },
+  {
+    label: 'exec-ms',
+    value: 'Exec(ms)',
+  },
+  {
+    label: 'exec-percent',
+    value: 'Exec(%)',
+  },
+  {
+    label: 'exec-percent',
+    value: 'Duration(%)',
+  },
+  {
+    label: 'self',
+    value: 'Self(ms)',
+  },
+  {
+    label: 'tags',
+    value: 'Tags',
+  },
+];
diff --git a/ui/src/components/TraceTree/Table/table.scss 
b/ui/src/components/TraceTree/Table/table.scss
new file mode 100644
index 00000000..5f10e315
--- /dev/null
+++ b/ui/src/components/TraceTree/Table/table.scss
@@ -0,0 +1,47 @@
+/**
+ * Licensed to the 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.
+ * The 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.
+ */
+.argument {
+  width: 150px;
+}
+
+.start-time {
+  width: 150px;
+}
+
+.exec-ms {
+  width: 120px;
+}
+
+.exec-percent {
+  width: 120px;
+}
+
+.self {
+  width: 100px;
+}
+
+.agent {
+  width: 150px;
+}
+
+.application {
+  width: 150px;
+  text-align: center;
+}
+.tags {
+  width: 230px;
+}
diff --git a/ui/src/components/TraceTree/TraceContent.vue 
b/ui/src/components/TraceTree/TraceContent.vue
new file mode 100644
index 00000000..3c7a3b10
--- /dev/null
+++ b/ui/src/components/TraceTree/TraceContent.vue
@@ -0,0 +1,142 @@
+<!-- Licensed to the 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.
+The 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. -->
+<template>
+  <div class="detail-section-timeline" style="display: flex; flex-direction: 
column">
+    <MinTimeline
+      v-show="minTimelineVisible"
+      :spanList="spanList"
+      :minTimestamp="minTimestamp"
+      :maxTimestamp="maxTimestamp"
+      @updateSelectedMaxTimestamp="handleSelectedMaxTimestamp"
+      @updateSelectedMinTimestamp="handleSelectedMinTimestamp"
+    />
+    <div class="timeline-tool">
+      <el-button :icon="DCaret" size="small" @click="toggleMinTimeline" />
+    </div>
+    <TableGraph
+      :data="traceData"
+      :selectedMaxTimestamp="selectedMaxTimestamp"
+      :selectedMinTimestamp="selectedMinTimestamp"
+      :minTimestamp="minTimestamp"
+      :maxTimestamp="maxTimestamp"
+    />
+  </div>
+</template>
+
+<script setup>
+  import { ref, computed } from 'vue';
+  import { DCaret } from '@element-plus/icons-vue';
+  import MinTimeline from './MinTimeline.vue';
+  import TableGraph from './Table/Index.vue';
+
+  const props = defineProps({
+    trace: Object,
+  });
+  const traceData = computed(() => {
+    return props.trace.spans.map((span) => convertTree(span));
+  });
+  const spanList = computed(() => {
+    return getAllNodes({ label: 'TRACE_ROOT', children: traceData.value });
+  });
+  // Time range like xScale domain [0, max]
+  const minTimestamp = computed(() => {
+    if (!traceData.value.length) return 0;
+    return Math.min(...spanList.value.filter((s) => s.startTime > 0).map((s) 
=> s.startTime));
+  });
+
+  const maxTimestamp = computed(() => {
+    const timestamps = spanList.value.map((span) => span.endTime || 0);
+    if (timestamps.length === 0) return 0;
+
+    return Math.max(...timestamps);
+  });
+  const selectedMaxTimestamp = ref(maxTimestamp.value);
+  const selectedMinTimestamp = ref(minTimestamp.value);
+  const minTimelineVisible = ref(true);
+
+  function handleSelectedMaxTimestamp(value) {
+    selectedMaxTimestamp.value = value;
+  }
+
+  function handleSelectedMinTimestamp(value) {
+    selectedMinTimestamp.value = value;
+  }
+
+  function toggleMinTimeline() {
+    minTimelineVisible.value = !minTimelineVisible.value;
+  }
+
+  function convertTree(d) {
+    d.endTime = new Date(d.endTime).getTime();
+    d.startTime = new Date(d.startTime).getTime();
+    d.duration = Number(d.duration);
+    d.label = d.message;
+    let selfDuration = d.duration;
+    if (d.children && d.children.length > 0) {
+      for (const i of d.children) {
+        selfDuration -= i.duration;
+        i.endTime = new Date(i.endTime).getTime();
+        i.startTime = new Date(i.startTime).getTime();
+        convertTree(i);
+      }
+    }
+    d.selfDuration = selfDuration < 0 ? 0 : selfDuration;
+    return d;
+  }
+  function getAllNodes(tree) {
+    const nodes = [];
+    const stack = [tree];
+
+    while (stack.length > 0) {
+      const node = stack.pop();
+      nodes.push(node);
+
+      if (node?.children && node.children.length > 0) {
+        for (let i = node.children.length - 1; i >= 0; i--) {
+          stack.push(node.children[i]);
+        }
+      }
+    }
+
+    return nodes;
+  }
+</script>
+
+<style lang="scss" scoped>
+  .trace-info h3 {
+    margin: 0 0 10px;
+    color: var(--el-text-color-primary);
+    font-size: 18px;
+    font-weight: 600;
+  }
+
+  .trace-meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 20px;
+  }
+
+  .detail-section-timeline {
+    width: 100%;
+  }
+
+  .timeline-tool {
+    justify-content: end;
+    padding: 10px 5px;
+    border-bottom: 1px solid var(--el-border-color-light);
+    display: flex;
+    flex-direction: row;
+  }
+</style>
diff --git a/ui/src/components/TraceTree/useHooks.js 
b/ui/src/components/TraceTree/useHooks.js
new file mode 100644
index 00000000..fa08ca43
--- /dev/null
+++ b/ui/src/components/TraceTree/useHooks.js
@@ -0,0 +1,111 @@
+/**
+ * Licensed to the 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.
+ * The 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.
+ */
+import { ref, computed } from 'vue';
+
+export const adjustPercentValue = (value) => {
+  if (value <= 0) {
+    return 0;
+  }
+  if (value >= 100) {
+    return 100;
+  }
+  return value;
+};
+
+const calculateX = ({ parentRect, x, opositeX, isSmallerThanOpositeX }) => {
+  let value = ((x - parentRect.left) / (parentRect.right - parentRect.left)) * 
100;
+  if (isSmallerThanOpositeX) {
+    if (value >= opositeX) {
+      value = opositeX - 1;
+    }
+  } else if (value <= opositeX) {
+    value = opositeX + 1;
+  }
+  return adjustPercentValue(value);
+};
+
+export const useRangeTimestampHandler = ({
+  rootEl,
+  minTimestamp,
+  maxTimestamp,
+  selectedTimestamp,
+  isSmallerThanOpositeX,
+  setTimestamp,
+}) => {
+  const currentX = ref(NaN);
+  const mouseDownX = ref(NaN);
+  const isDragging = ref(false);
+  const selectedTimestampComputed = ref(selectedTimestamp);
+  const opositeX = computed(() => {
+    return ((selectedTimestampComputed.value - minTimestamp) / (maxTimestamp - 
minTimestamp)) * 100;
+  });
+
+  const onMouseMove = (e) => {
+    if (!rootEl) {
+      return;
+    }
+    const x = calculateX({
+      parentRect: rootEl.getBoundingClientRect(),
+      x: e.pageX,
+      opositeX: opositeX.value,
+      isSmallerThanOpositeX,
+    });
+    currentX.value = x;
+  };
+
+  const onMouseUp = (e) => {
+    if (!rootEl) {
+      return;
+    }
+
+    const x = calculateX({
+      parentRect: rootEl.getBoundingClientRect(),
+      x: e.pageX,
+      opositeX: opositeX.value,
+      isSmallerThanOpositeX,
+    });
+    const timestamp = (x / 100) * (maxTimestamp - minTimestamp) + minTimestamp;
+    selectedTimestampComputed.value = timestamp;
+    setTimestamp(timestamp);
+    currentX.value = undefined;
+    mouseDownX.value = undefined;
+    isDragging.value = false;
+
+    document.removeEventListener('mousemove', onMouseMove);
+    document.removeEventListener('mouseup', onMouseUp);
+  };
+
+  const onMouseDown = (e) => {
+    if (!rootEl) {
+      return;
+    }
+    const x = calculateX({
+      parentRect: rootEl.getBoundingClientRect(),
+      x: e.currentTarget.getBoundingClientRect().x + 3,
+      opositeX: opositeX.value,
+      isSmallerThanOpositeX,
+    });
+    currentX.value = x;
+    mouseDownX.value = x;
+    isDragging.value = true;
+
+    document.addEventListener('mousemove', onMouseMove);
+    document.addEventListener('mouseup', onMouseUp);
+  };
+
+  return { currentX, mouseDownX, onMouseDown, isDragging };
+};
diff --git a/ui/src/components/common/data.js b/ui/src/components/common/data.js
index d34f2125..71357bd0 100644
--- a/ui/src/components/common/data.js
+++ b/ui/src/components/common/data.js
@@ -65,3 +65,32 @@ function createRange(duration) {
   const start = new Date(end.getTime() - duration);
   return [start, end];
 }
+
+// catalog to group type
+export const CatalogToGroupType = {
+  CATALOG_MEASURE: 'measure',
+  CATALOG_STREAM: 'stream',
+  CATALOG_PROPERTY: 'property',
+  CATALOG_TRACE: 'trace',
+};
+
+// group type to catalog
+export const GroupTypeToCatalog = {
+  measure: 'CATALOG_MEASURE',
+  stream: 'CATALOG_STREAM',
+  property: 'CATALOG_PROPERTY',
+  trace: 'CATALOG_TRACE',
+};
+
+export const TypeMap = {
+  topNAggregation: 'topn-agg',
+  indexRule: 'index-rule',
+  indexRuleBinding: 'index-rule-binding',
+  children: 'children',
+};
+
+export const SupportedIndexRuleTypes = [
+  CatalogToGroupType.CATALOG_STREAM,
+  CatalogToGroupType.CATALOG_MEASURE,
+  CatalogToGroupType.CATALOG_TRACE,
+];
diff --git a/ui/src/styles/custom.scss b/ui/src/styles/custom.scss
index 7661936b..7574a74d 100644
--- a/ui/src/styles/custom.scss
+++ b/ui/src/styles/custom.scss
@@ -58,4 +58,13 @@ html {
   --size-title: 1.2em;
   --size-big: 1.4em;
   --font-family-main: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Sans 
Unicode', Verdana, sans-serif;
+  --font-color: #3d444f;
+  --text-color: #fff;
+  --theme-background: #fff;
+  --active-color: var(--el-color-primary);
+  --disabled-color: #ccc;
+}
+
+div {
+  box-sizing: border-box;
 }
diff --git a/ui/src/utils/debounce.js b/ui/src/utils/debounce.js
new file mode 100644
index 00000000..24a05c9e
--- /dev/null
+++ b/ui/src/utils/debounce.js
@@ -0,0 +1,29 @@
+/**
+ * Licensed to the 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.
+ * The 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 function debounce(callback, dur) {
+  let timer;
+
+  return function () {
+    if (timer) {
+      clearTimeout(timer);
+    }
+    timer = setTimeout(function () {
+      callback();
+    }, dur);
+  };
+}
diff --git a/ui/src/utils/mutation.js b/ui/src/utils/mutation.js
new file mode 100644
index 00000000..73ed266a
--- /dev/null
+++ b/ui/src/utils/mutation.js
@@ -0,0 +1,44 @@
+/**
+ * Licensed to the 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.
+ * The 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 class mutationObserver {
+  static mutationObserverMap = new Map();
+
+  static create(key, callback) {
+    const observer = new MutationObserver(callback);
+    mutationObserver.mutationObserverMap.set(key, observer);
+  }
+
+  static observe(key, target, options) {
+    const observer = mutationObserver.mutationObserverMap.get(key);
+    if (observer) {
+      observer.observe(target, options);
+    }
+  }
+
+  static deleteObserve(key) {
+    this.disconnect(key);
+    this.mutationObserverMap.delete(key);
+  }
+
+  static disconnect(key) {
+    const observer = this.mutationObserverMap.get(key);
+    if (observer) {
+      observer.disconnect();
+    }
+  }
+}
diff --git a/ui/src/views/Measure/index.vue b/ui/src/views/Measure/index.vue
index b5ce2f50..d8ad6a73 100644
--- a/ui/src/views/Measure/index.vue
+++ b/ui/src/views/Measure/index.vue
@@ -21,7 +21,7 @@
   import GroupTree from '@/components/GroupTree/index.vue';
   import TopNav from '@/components/TopNav/index.vue';
   import { reactive } from 'vue';
-  import { CatalogToGroupType } from '@/components/GroupTree/data';
+  import { CatalogToGroupType } from '@/components/common/data';
 
   const data = reactive({
     width: '200px',
diff --git a/ui/src/views/Property/index.vue b/ui/src/views/Property/index.vue
index 4f15c1ab..21ce8902 100644
--- a/ui/src/views/Property/index.vue
+++ b/ui/src/views/Property/index.vue
@@ -21,7 +21,7 @@
   import { reactive } from 'vue';
   import GroupTree from '@/components/GroupTree/index.vue';
   import TopNav from '@/components/TopNav/index.vue';
-  import { CatalogToGroupType } from '@/components/GroupTree/data';
+  import { CatalogToGroupType } from '@/components/common/data';
 
   const data = reactive({
     width: '200px',
diff --git a/ui/src/views/Stream/index.vue b/ui/src/views/Stream/index.vue
index 1a842a39..724305fa 100644
--- a/ui/src/views/Stream/index.vue
+++ b/ui/src/views/Stream/index.vue
@@ -21,7 +21,7 @@
   import { reactive } from 'vue';
   import GroupTree from '@/components/GroupTree/index.vue';
   import TopNav from '@/components/TopNav/index.vue';
-  import { CatalogToGroupType } from '@/components/GroupTree/data';
+  import { CatalogToGroupType } from '@/components/common/data';
 
   const data = reactive({
     width: '200px',
diff --git a/ui/src/views/Trace/index.vue b/ui/src/views/Trace/index.vue
index 34342c0b..2148334b 100644
--- a/ui/src/views/Trace/index.vue
+++ b/ui/src/views/Trace/index.vue
@@ -21,7 +21,7 @@
   import { reactive } from 'vue';
   import GroupTree from '@/components/GroupTree/index.vue';
   import TopNav from '@/components/TopNav/index.vue';
-  import { CatalogToGroupType } from '@/components/GroupTree/data';
+  import { CatalogToGroupType } from '@/components/common/data';
 
   const data = reactive({
     width: '200px',

Reply via email to