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',