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