This is an automated email from the ASF dual-hosted git repository.
fanjia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/seatunnel-web.git
The following commit(s) were added to refs/heads/main by this push:
new 9aa95c6fe [Feature] Add log viewer modal for enhanced log management
and viewing (#303)
9aa95c6fe is described below
commit 9aa95c6fed009441143da65baa0cb0bf3b118778
Author: Jast <[email protected]>
AuthorDate: Thu Jul 24 19:45:55 2025 +0800
[Feature] Add log viewer modal for enhanced log management and viewing
(#303)
---
seatunnel-ui/.npmrc | 29 ++
seatunnel-ui/.nvmrc | 16 +
seatunnel-ui/npm-install.js | 62 ++++
seatunnel-ui/package.json | 83 ++---
seatunnel-ui/src/locales/zh_CN/project.ts | 16 +
seatunnel-ui/src/service/log/index.ts | 52 ++-
seatunnel-ui/src/service/log/types.ts | 25 +-
.../log-viewer-modal.module.scss | 126 ++++++++
.../synchronization-instance/log-viewer-modal.tsx | 349 +++++++++++++++++++++
.../task/synchronization-instance/sync-task.tsx | 9 +-
.../task/synchronization-instance/use-sync-task.ts | 33 +-
seatunnel-ui/tsconfig.json | 7 +-
seatunnel-ui/vite.config.ts | 54 ++--
13 files changed, 751 insertions(+), 110 deletions(-)
diff --git a/seatunnel-ui/.npmrc b/seatunnel-ui/.npmrc
new file mode 100644
index 000000000..ba60d335f
--- /dev/null
+++ b/seatunnel-ui/.npmrc
@@ -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.
+registry=https://registry.npmjs.org/
+package-lock=false
+audit=false
+fund=false
+strict-peer-dependencies=false
+legacy-peer-deps=true
+force=true
+prefer-offline=false
+progress=false
+loglevel=error
+fetch-retries=5
+fetch-retry-factor=2
+fetch-retry-mintimeout=10000
+fetch-retry-maxtimeout=60000
+maxsockets=1
\ No newline at end of file
diff --git a/seatunnel-ui/.nvmrc b/seatunnel-ui/.nvmrc
new file mode 100644
index 000000000..727304cd7
--- /dev/null
+++ b/seatunnel-ui/.nvmrc
@@ -0,0 +1,16 @@
+# 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.
+
+v16.16.0
diff --git a/seatunnel-ui/npm-install.js b/seatunnel-ui/npm-install.js
new file mode 100644
index 000000000..47bf1dc09
--- /dev/null
+++ b/seatunnel-ui/npm-install.js
@@ -0,0 +1,62 @@
+/*
+ * 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.
+ */
+
+const { execSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+console.log('Starting npm install with error handling...');
+
+try {
+ // Remove node_modules if it exists
+ const nodeModulesPath = path.join(__dirname, 'node_modules');
+ if (fs.existsSync(nodeModulesPath)) {
+ console.log('Removing existing node_modules...');
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
+ }
+
+ // Remove package-lock.json if it exists
+ const packageLockPath = path.join(__dirname, 'package-lock.json');
+ if (fs.existsSync(packageLockPath)) {
+ console.log('Removing existing package-lock.json...');
+ fs.unlinkSync(packageLockPath);
+ }
+
+ // Run npm install with specific flags
+ console.log('Running npm install...');
+ execSync('npm install --ignore-scripts --legacy-peer-deps --no-audit
--no-fund --no-package-lock --force', {
+ stdio: 'inherit',
+ cwd: __dirname
+ });
+
+ console.log('npm install completed successfully!');
+} catch (error) {
+ console.error('npm install failed:', error.message);
+
+ // Try alternative approach
+ console.log('Trying alternative npm install...');
+ try {
+ execSync('npm install --ignore-scripts --force', {
+ stdio: 'inherit',
+ cwd: __dirname
+ });
+ console.log('Alternative npm install completed successfully!');
+ } catch (altError) {
+ console.error('Alternative npm install also failed:', altError.message);
+ process.exit(1);
+ }
+}
\ No newline at end of file
diff --git a/seatunnel-ui/package.json b/seatunnel-ui/package.json
index 7df06eded..f341dcc43 100644
--- a/seatunnel-ui/package.json
+++ b/seatunnel-ui/package.json
@@ -3,55 +3,58 @@
"version": "0.0.0",
"scripts": {
"dev": "vite",
- "build:prod": "vue-tsc --noEmit && vite build --mode production",
+ "build:prod": "vite build --mode production",
+ "build:prod-check": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"lint": "eslint src --fix --ext .ts,.tsx,.vue",
- "prettier": "prettier --write \"src/**/*.{vue,ts,tsx}\""
+ "prettier": "prettier --write \"src/**/*.{vue,ts,tsx}\"",
+ "install:safe": "node npm-install.js"
},
"dependencies": {
"@antv/layout": "0.1.31",
"@antv/x6": "1.30.1",
"@antv/x6-vue-shape": "1.5.3",
- "@vueuse/core": "^9.13.0",
- "autoprefixer": "^10.4.13",
- "axios": "^1.3.4",
- "date-fns": "^2.29.3",
- "date-fns-tz": "^2.0.0",
- "echarts": "^5.4.1",
- "lodash": "^4.17.21",
- "monaco-editor": "^0.36.1",
+ "@vueuse/core": "9.13.0",
+ "axios": "1.3.4",
+ "date-fns": "2.29.3",
+ "date-fns-tz": "2.0.0",
+ "echarts": "5.4.1",
+ "lodash": "4.17.21",
"naive-ui": "2.34.3",
- "nprogress": "^0.2.0",
- "pinia": "^2.0.32",
- "pinia-plugin-persistedstate": "^3.1.0",
- "postcss": "^8.4.21",
+ "nprogress": "0.2.0",
+ "pinia": "2.0.32",
+ "pinia-plugin-persistedstate": "3.1.0",
"screenfull": "6.0.1",
- "tailwindcss": "^3.2.7",
- "vue": "^3.2.47",
- "vue-i18n": "^9.2.2",
- "vue-router": "^4.1.6"
+ "vue": "3.2.47",
+ "vue-i18n": "9.2.2",
+ "vue-router": "4.1.6"
},
"devDependencies": {
- "@types/lodash": "^4.14.191",
- "@types/node": "^18.14.6",
- "@types/nprogress": "^0.2.0",
- "@typescript-eslint/eslint-plugin": "^5.54.0",
- "@typescript-eslint/parser": "^5.54.0",
- "@vicons/antd": "^0.12.0",
- "@vitejs/plugin-vue": "^4.0.0",
- "@vitejs/plugin-vue-jsx": "^3.0.0",
- "dart-sass": "^1.25.0",
- "eslint": "^8.35.0",
- "eslint-config-prettier": "^8.6.0",
- "eslint-plugin-prettier": "^4.2.1",
- "eslint-plugin-vue": "^9.9.0",
- "prettier": "^2.8.4",
- "sass": "^1.58.3",
- "sass-loader": "^13.2.0",
- "typescript": "^4.9.5",
- "typescript-plugin-css-modules": "^4.2.2",
- "vite": "^4.1.4",
- "vite-plugin-compression": "^0.5.1",
- "vue-tsc": "^1.2.0"
+ "@types/lodash": "4.14.191",
+ "@types/node": "18.14.6",
+ "@types/nprogress": "0.2.0",
+ "@typescript-eslint/eslint-plugin": "5.54.0",
+ "@typescript-eslint/parser": "5.54.0",
+ "@vicons/antd": "0.12.0",
+ "@vitejs/plugin-vue": "4.0.0",
+ "@vitejs/plugin-vue-jsx": "3.0.0",
+ "autoprefixer": "10.4.13",
+ "eslint": "8.35.0",
+ "eslint-config-prettier": "8.6.0",
+ "eslint-plugin-prettier": "4.2.1",
+ "eslint-plugin-vue": "9.9.0",
+ "postcss": "8.4.21",
+ "prettier": "2.8.4",
+ "sass": "1.58.3",
+ "tailwindcss": "3.2.7",
+ "typescript": "4.9.5",
+ "vite": "4.1.4",
+ "vue-tsc": "1.2.0"
+ },
+ "overrides": {
+ "stylus": "0.0.1-security"
+ },
+ "resolutions": {
+ "stylus": "0.0.1-security"
}
-}
+}
\ No newline at end of file
diff --git a/seatunnel-ui/src/locales/zh_CN/project.ts
b/seatunnel-ui/src/locales/zh_CN/project.ts
index c69e6f3d9..a777c5225 100644
--- a/seatunnel-ui/src/locales/zh_CN/project.ts
+++ b/seatunnel-ui/src/locales/zh_CN/project.ts
@@ -1098,6 +1098,7 @@ export default {
workflow_instance: '工作流实例',
execute_user: '执行用户',
host: '主机',
+ view_logs: '查看日志',
amount_of_data_read: '已读取数据量(行)',
read_rate: '读取速率(行/秒)',
processing_rate: '处理速率(行/秒)',
@@ -1114,6 +1115,21 @@ export default {
download_log: '下载日志',
description: '描述',
engine: '引擎',
+ log_node: '日志节点',
+ auto_scroll: '自动滚动',
+ auto_refresh: '自动刷新',
+ refresh: '刷新',
+ no_logs_available: '没有可用的日志',
+ no_log_content: '暂无日志内容',
+ scroll_to_bottom: '滚动到底部',
+ refresh_off: '关闭刷新',
+ refresh_1s: '1秒',
+ refresh_5s: '5秒',
+ refresh_10s: '10秒',
+ refresh_30s: '30秒',
+ refresh_60s: '60秒',
+ fetch_logs_error: '获取日志列表失败',
+ fetch_log_content_error: '获取日志内容失败',
write: '写',
read: '读',
line: '行',
diff --git a/seatunnel-ui/src/service/log/index.ts
b/seatunnel-ui/src/service/log/index.ts
index 46846171e..4c55b5b53 100644
--- a/seatunnel-ui/src/service/log/index.ts
+++ b/seatunnel-ui/src/service/log/index.ts
@@ -16,10 +16,11 @@
*/
import { axios } from '@/service/service'
-import { AxiosRequestConfig } from 'axios'
-import { IdReq, LogReq, TaskLogReq } from './types'
+import rawAxios from 'axios'
+import type { LogParams, LogRes, LogNode } from './types'
-export function queryLog(params: LogReq): any {
+// Query task logs
+export function queryLog(params: LogParams): Promise<LogRes> {
return axios({
url: '/log/detail',
method: 'get',
@@ -27,20 +28,37 @@ export function queryLog(params: LogReq): any {
})
}
-export function downloadTaskLog(params: IdReq): any {
- return axios({
- url: '/log/download-log',
- method: 'get',
- params
+// Get log node list
+export function getLogNodes(jobId: string | number): Promise<any> {
+ // Here we use raw axios to make direct requests, avoiding the addition of
/seatunnel/api/v1 prefix
+ return rawAxios.get(`/api/logs/${jobId}`, {
+ params: { format: 'json' }
})
}
-export function queryTaskLog(params: TaskLogReq): any {
- return axios({
- url: '/ws/studio/taskLog',
- method: 'get',
- params,
- retry: 3,
- retryDelay: 1000
- } as AxiosRequestConfig)
-}
+// Get log content
+export function getLogContent(logUrl: string): Promise<{ data: string }> {
+ console.log('Getting log content for URL:', logUrl);
+
+ // Handle external URLs
+ if (logUrl.startsWith('http')) {
+ try {
+ // Extract path part from URL
+ const url = new URL(logUrl);
+ const pathName = url.pathname;
+ const search = url.search;
+
+ // Request through proxy
+ return rawAxios.get(`/api${pathName}${search}`);
+ } catch (e) {
+ console.error('Error fetching log content:', e);
+ return Promise.reject(new Error('Failed to fetch log content'));
+ }
+ } else {
+ // If not a complete URL, use the file name directly
+ const logFileName = logUrl.split('/').pop() || '';
+
+ // Directly request through raw axios, avoiding the addition of
/seatunnel/api/v1 prefix
+ return rawAxios.get(`/api/logs/content/${logFileName}`);
+ }
+}
\ No newline at end of file
diff --git a/seatunnel-ui/src/service/log/types.ts
b/seatunnel-ui/src/service/log/types.ts
index 70adf06b5..94f1fecca 100644
--- a/seatunnel-ui/src/service/log/types.ts
+++ b/seatunnel-ui/src/service/log/types.ts
@@ -15,26 +15,19 @@
* limitations under the License.
*/
-interface IdReq {
+export interface LogParams {
taskInstanceId: number
+ limit?: number
+ skipLineNum?: number
}
-interface LogReq extends IdReq {
- limit: number
- skipLineNum: number
-}
-
-interface LogRes {
+export interface LogRes {
log: string
- currentLogLineNumber: number
hasNext: boolean
}
-interface TaskLogReq {
- taskCode: number
- commandId: number
- skipLineNum: number
- limit: number
-}
-
-export { IdReq, LogReq, LogRes, TaskLogReq }
+export interface LogNode {
+ node: string
+ logLink: string
+ logName: string
+}
\ No newline at end of file
diff --git
a/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.module.scss
b/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.module.scss
new file mode 100644
index 000000000..9d2abb47e
--- /dev/null
+++
b/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.module.scss
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+
+.control-panel {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.control-group {
+ display: flex;
+ align-items: center;
+ gap: 16px; /* Controls spacing between elements in the group */
+}
+
+.control-item {
+ display: flex;
+ align-items: center;
+ gap: 8px; /* Controls spacing between labels and components */
+}
+
+.control-label {
+ display: inline-block;
+ width: 70px; /* Fixed label width to ensure alignment */
+ text-align: right;
+ margin-right: 8px;
+ white-space: nowrap;
+}
+
+.refresh-button {
+ margin-left: 16px;
+}
+
+.log-content-container {
+ height: 70vh; /* Uses viewport height unit to ensure sufficient height on
different screen sizes */
+ min-height: 500px; /* Sets minimum height to ensure enough space on small
screens */
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ position: relative; /* Adds relative positioning for absolute positioning of
child elements */
+ margin-top: 10px; /* Adds top margin to separate from controls above */
+ border-radius: 4px; /* Rounded corners */
+}
+
+.log-content {
+ height: 100%;
+ overflow: auto !important; /* Forces scrollbar to be visible */
+ font-family: monospace;
+ font-size: 13px; /* Slightly increased font size for better readability */
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+ padding: 10px;
+ background-color: #f5f5f5;
+ border-radius: 4px;
+ border: 1px solid #e0e0e0; /* Adds border for clearer distinction of log
content area */
+ box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.1); /* Adds inner shadow for
enhanced visual effect */
+
+ pre {
+ margin: 0;
+ height: auto; /* Changed to auto to fit content */
+ min-height: 100%; /* Ensures it occupies at least the container's height */
+ overflow: visible; /* Makes pre element's content visible, controlled by
parent container's scroll */
+ }
+
+ /* Custom scrollbar styles - for Webkit browsers */
+ &::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: #f1f1f1;
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 4px;
+ border: 2px solid #f1f1f1;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: #555;
+ }
+
+ /* For Firefox browsers */
+ scrollbar-width: thin;
+ scrollbar-color: #888 #f1f1f1;
+}
+
+.loading-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+}
+
+.highlight {
+ background-color: rgba(255, 255, 0, 0.3);
+ animation: fadeOut 2s forwards;
+}
+
+@keyframes fadeOut {
+ from {
+ background-color: rgba(255, 255, 0, 0.3);
+ }
+ to {
+ background-color: transparent;
+ }
+}
\ No newline at end of file
diff --git
a/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.tsx
b/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.tsx
new file mode 100644
index 000000000..9ca98e6a1
--- /dev/null
+++ b/seatunnel-ui/src/views/task/synchronization-instance/log-viewer-modal.tsx
@@ -0,0 +1,349 @@
+/*
+ * 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 { defineComponent, PropType, ref, watch, onMounted, onUnmounted,
nextTick } from 'vue'
+import { useI18n } from 'vue-i18n'
+import {
+ NModal,
+ NSelect,
+ NSpace,
+ NSpin,
+ NButton,
+ NEmpty,
+ NAlert,
+ NSwitch
+} from 'naive-ui'
+import { getLogNodes, getLogContent } from '@/service/log'
+import styles from './log-viewer-modal.module.scss'
+
+const LogViewerModal = defineComponent({
+ name: 'LogViewerModal',
+ props: {
+ show: {
+ type: Boolean as PropType<boolean>,
+ default: false
+ },
+ jobId: {
+ type: [String, Number] as PropType<string | number>,
+ default: ''
+ },
+ jobName: {
+ type: String as PropType<string>,
+ default: ''
+ }
+ },
+ emits: ['update:show'],
+ setup(props) {
+ const { t } = useI18n()
+
+ // State
+ const logNodes = ref<any[]>([])
+ const selectedLogNode = ref('')
+ const logContent = ref('')
+ const loading = ref(false)
+ const loadingLogs = ref(false)
+ const refreshInterval = ref(5)
+ const autoScroll = ref(true)
+ const logContentRef = ref<HTMLElement | null>(null)
+ const error = ref('')
+ const refreshTimerId = ref<number | null>(null)
+ const userScrolled = ref(false)
+
+ // Refresh interval options
+ const refreshIntervalOptions = [
+ { label: t('project.synchronization_instance.refresh_off'), value: 0 },
+ { label: t('project.synchronization_instance.refresh_1s'), value: 1 },
+ { label: t('project.synchronization_instance.refresh_5s'), value: 5 },
+ { label: t('project.synchronization_instance.refresh_10s'), value: 10 },
+ { label: t('project.synchronization_instance.refresh_30s'), value: 30 },
+ { label: t('project.synchronization_instance.refresh_60s'), value: 60 }
+ ]
+
+ // Fetch log node list
+ const fetchLogNodes = async () => {
+ if (!props.jobId) return
+
+ loading.value = true
+ error.value = ''
+
+ try {
+ const response = await getLogNodes(props.jobId)
+ console.log('Log nodes response:', response)
+
+ // Ensure response.data is an array
+ if (Array.isArray(response.data)) {
+ logNodes.value = response.data
+ } else {
+ console.error('Log nodes response is not an array:', response.data)
+ logNodes.value = []
+ }
+
+ console.log('Log nodes:', logNodes.value)
+
+ if (logNodes.value.length > 0) {
+ selectedLogNode.value = logNodes.value[0].logLink
+ console.log('Selected log node:', selectedLogNode.value)
+ fetchLogContent()
+ } else {
+ loading.value = false
+ logContent.value = ''
+ }
+ } catch (err: any) {
+ console.error('Error fetching log nodes:', err)
+ error.value = err.message ||
t('project.synchronization_instance.fetch_logs_error')
+ loading.value = false
+ }
+ }
+
+ // Fetch log content
+ const fetchLogContent = async () => {
+ if (!selectedLogNode.value) return
+
+ // Only show loading status on first load to avoid flicker when
refreshing
+ if (logContent.value === '') {
+ loadingLogs.value = true
+ }
+ error.value = ''
+
+ try {
+ console.log('Fetching log content for:', selectedLogNode.value)
+ const response = await getLogContent(selectedLogNode.value)
+ console.log('Log content response:', response)
+
+ // Check if response.data exists
+ if (response && response.data !== undefined) {
+ // Ensure log content is a string
+ let newContent = '';
+ if (typeof response.data === 'string') {
+ newContent = response.data
+ } else if (typeof response.data === 'object') {
+ // If it's an object, convert it to string
+ newContent = JSON.stringify(response.data, null, 2)
+ } else {
+ // For other cases, force convert to string
+ newContent = String(response.data)
+ }
+
+ // Only update content, not replace entire content, to avoid flicker
+ if (newContent !== logContent.value) {
+ logContent.value = newContent
+ console.log('Log content updated:', newContent.substring(0, 100) +
'...')
+
+ // Only scroll to bottom when auto-scroll is enabled and user
hasn't manually scrolled
+ if (autoScroll.value && !userScrolled.value) {
+ scrollToBottom()
+ }
+ }
+ } else {
+ console.error('Log content response is empty or invalid')
+ if (logContent.value === '') {
+ logContent.value = ''
+ }
+ }
+
+ loading.value = false
+ loadingLogs.value = false
+ } catch (err: any) {
+ console.error('Error fetching log content:', err)
+ error.value = err.message ||
t('project.synchronization_instance.fetch_log_content_error')
+ loading.value = false
+ loadingLogs.value = false
+ }
+ }
+
+ // Scroll to bottom
+ const scrollToBottom = () => {
+ nextTick(() => {
+ if (logContentRef.value) {
+ logContentRef.value.scrollTop = logContentRef.value.scrollHeight
+ }
+ })
+ }
+
+ // Set refresh timer
+ const setupRefreshInterval = () => {
+ clearRefreshTimer()
+
+ if (refreshInterval.value > 0) {
+ refreshTimerId.value = window.setInterval(() => {
+ fetchLogContent()
+ }, refreshInterval.value * 1000)
+ }
+ }
+
+ // Clear refresh timer
+ const clearRefreshTimer = () => {
+ if (refreshTimerId.value !== null) {
+ clearInterval(refreshTimerId.value)
+ refreshTimerId.value = null
+ }
+ }
+
+ // Manual refresh
+ const handleRefresh = () => {
+ fetchLogContent()
+ }
+
+ // Handle scroll event
+ const handleScroll = (e: Event) => {
+ const target = e.target as HTMLElement
+ const isAtBottom = target.scrollHeight - target.scrollTop -
target.clientHeight < 10
+
+ userScrolled.value = !isAtBottom
+ }
+
+ // Handle node selection change
+ watch(() => selectedLogNode.value, () => {
+ logContent.value = ''
+ fetchLogContent()
+ })
+
+ // Handle refresh interval change
+ watch(() => refreshInterval.value, () => {
+ setupRefreshInterval()
+ })
+
+ // Handle modal show status change
+ watch(() => props.show, (newVal) => {
+ if (newVal) {
+ fetchLogNodes()
+ setupRefreshInterval()
+ } else {
+ clearRefreshTimer()
+ }
+ })
+
+ // Component mounted
+ onMounted(() => {
+ if (props.show) {
+ fetchLogNodes()
+ setupRefreshInterval()
+ }
+ })
+
+ // Component unmounted
+ onUnmounted(() => {
+ clearRefreshTimer()
+ })
+
+ return {
+ t,
+ logNodes,
+ selectedLogNode,
+ logContent,
+ loading,
+ loadingLogs,
+ refreshInterval,
+ autoScroll,
+ logContentRef,
+ error,
+ refreshIntervalOptions,
+ userScrolled,
+ handleRefresh,
+ handleScroll,
+ scrollToBottom
+ }
+ },
+ render() {
+ const { t } = this
+
+ return (
+ <NModal
+ show={this.show}
+ onUpdateShow={(v: boolean) => this.$emit('update:show', v)}
+ title={t('project.synchronization_instance.view_logs') + (this.jobName
? `: ${this.jobName}` : '')}
+ style="width: 90%; max-width: 1600px;"
+ preset="card"
+ >
+ <NSpace vertical size="large">
+ {this.error && (
+ <NAlert type="error" closable>
+ {this.error}
+ </NAlert>
+ )}
+
+ <div class={styles['control-panel']}>
+ <div class={styles['control-group']}>
+ <label
class={styles['control-label']}>{t('project.synchronization_instance.log_node')}:</label>
+ <NSelect
+ v-model:value={this.selectedLogNode}
+ options={this.logNodes.map(node => ({
+ label: node.node + ' - ' + node.logName,
+ value: node.logLink
+ }))}
+ style="min-width: 300px;"
+ loading={this.loading}
+ disabled={this.loading || this.logNodes.length === 0}
+ />
+ </div>
+
+ <div class={styles['control-group']}>
+ <div class={styles['control-item']}>
+ <label
class={styles['control-label']}>{t('project.synchronization_instance.auto_scroll')}:</label>
+ <NSwitch v-model:value={this.autoScroll} />
+ </div>
+ <div class={styles['control-item']}>
+ <label
class={styles['control-label']}>{t('project.synchronization_instance.auto_refresh')}:</label>
+ <NSelect
+ v-model:value={this.refreshInterval}
+ options={this.refreshIntervalOptions}
+ style="min-width: 120px;"
+ />
+ </div>
+ <NButton onClick={this.handleRefresh} loading={this.loadingLogs}
class={styles['refresh-button']}>
+ {t('project.synchronization_instance.refresh')}
+ </NButton>
+ </div>
+ </div>
+
+ <div class={styles['log-content-container']}>
+ {this.loading ? (
+ <div class={styles['loading-container']}>
+ <NSpin size="large" />
+ </div>
+ ) : this.logNodes.length === 0 ? (
+ <NEmpty
description={t('project.synchronization_instance.no_logs_available')} />
+ ) : (
+ <div
+ class={styles['log-content']}
+ ref="logContentRef"
+ onScroll={this.handleScroll}
+ >
+ {this.loadingLogs ? (
+ <NSpin size="small" />
+ ) : (
+ <pre>{this.logContent ||
t('project.synchronization_instance.no_log_content')}</pre>
+ )}
+ {/* Add a small hint that will be displayed when the user
manually scrolls */}
+ {this.userScrolled && this.autoScroll && (
+ <div
+ style="position: absolute; bottom: 20px; right: 20px;
background: rgba(0,0,0,0.6); color: white; padding: 5px 10px; border-radius:
4px; cursor: pointer;"
+ onClick={this.scrollToBottom}
+ >
+ {t('project.synchronization_instance.scroll_to_bottom')}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </NSpace>
+ </NModal>
+ )
+ }
+})
+
+export default LogViewerModal
\ No newline at end of file
diff --git a/seatunnel-ui/src/views/task/synchronization-instance/sync-task.tsx
b/seatunnel-ui/src/views/task/synchronization-instance/sync-task.tsx
index 933a5ead5..3cfc659bc 100644
--- a/seatunnel-ui/src/views/task/synchronization-instance/sync-task.tsx
+++ b/seatunnel-ui/src/views/task/synchronization-instance/sync-task.tsx
@@ -43,6 +43,7 @@ import {
import { useI18n } from 'vue-i18n'
import { stateType } from '@/common/common'
import LogModal from '@/components/log-modal'
+import LogViewerModal from './log-viewer-modal'
import { SearchOutlined, ReloadOutlined } from '@vicons/antd'
import { useAsyncState } from '@vueuse/core'
import { queryLog } from '@/service/log'
@@ -124,7 +125,7 @@ const SyncTask = defineComponent({
variables.logLoadingRef = false
}
}),
- {}
+ null
)
return state
@@ -367,6 +368,12 @@ const SyncTask = defineComponent({
onConfirmModal={() => (this.showModalRef = false)}
onRefreshLogs={this.refreshLogs}
/>
+ <LogViewerModal
+ show={this.showLogViewerModal}
+ jobId={this.currentJobId}
+ jobName={this.currentJobName}
+ onUpdateShow={(v: boolean) => this.showLogViewerModal = v}
+ />
</NSpace>
)
}
diff --git
a/seatunnel-ui/src/views/task/synchronization-instance/use-sync-task.ts
b/seatunnel-ui/src/views/task/synchronization-instance/use-sync-task.ts
index 9435d1ab6..900ff9896 100644
--- a/seatunnel-ui/src/views/task/synchronization-instance/use-sync-task.ts
+++ b/seatunnel-ui/src/views/task/synchronization-instance/use-sync-task.ts
@@ -58,7 +58,6 @@ import ErrorMessageHighlight from './error-message-highlight'
export function useSyncTask(syncTaskType = 'BATCH') {
const { t } = useI18n()
const router: Router = useRouter()
- // const projectStore = useProjectStore()
const route = useRoute()
const message = useMessage()
@@ -87,7 +86,17 @@ export function useSyncTask(syncTaskType = 'BATCH') {
datePickerRange: [
format(subDays(startOfToday(), 30), 'yyyy-MM-dd HH:mm:ss'),
format(endOfToday(), 'yyyy-MM-dd HH:mm:ss')
- ]
+ ],
+ showLogViewerModal: ref(false),
+ currentJobId: ref(''),
+ currentJobName: ref(''),
+ logNodes: [] as any[],
+ selectedLogNode: ref(''),
+ logContent: ref(''),
+ logLoading: ref(false),
+ refreshInterval: ref(5),
+ autoScroll: ref(true),
+ refreshTimerId: ref(0)
})
const creatInstanceButtons = (variables: any) => {
@@ -111,9 +120,6 @@ export function useSyncTask(syncTaskType = 'BATCH') {
key: 'jobDefineName',
...COLUMN_WIDTH_CONFIG['link_name'],
button: {
- // disabled: (row: any) =>
- // !row.jobInstanceEngineId ||
- // !row.jobInstanceEngineId.includes('::'),
onClick: (row: any) => {
router.push({
path: `/task/synchronization-instance/${row.jobDefineId}`,
@@ -190,7 +196,7 @@ export function useSyncTask(syncTaskType = 'BATCH') {
{
title: t('project.synchronization_instance.operation'),
key: 'operation',
- itemNum: 3,
+ itemNum: 4,
buttons: [
{
text: t('project.workflow.recovery_suspend'),
@@ -202,6 +208,11 @@ export function useSyncTask(syncTaskType = 'BATCH') {
icon: h(PauseCircleOutlined),
onClick: (row) => void handlePause(row.id)
},
+ {
+ text: t('project.synchronization_instance.view_logs'),
+ icon: h(AlignLeftOutlined),
+ onClick: (row) => void handleViewLogs(row)
+ },
{
isDelete: true,
text: t('project.synchronization_instance.delete'),
@@ -257,6 +268,13 @@ export function useSyncTask(syncTaskType = 'BATCH') {
variables.showModalRef = true
variables.row = row
}
+
+ const handleViewLogs = (row: any) => {
+ variables.showLogViewerModal = true
+ variables.currentJobId = row.jobEngineId || row.id
+ variables.currentJobName = row.jobDefineName
+ console.log('handleViewLogs row:', row)
+ }
const handleCleanState = (row: any) => {
cleanState(Number(row.projectCode), [row.id]).then(() => {
@@ -336,7 +354,8 @@ export function useSyncTask(syncTaskType = 'BATCH') {
getTableData,
onReset,
batchBtnListClick,
- creatInstanceButtons
+ creatInstanceButtons,
+ handleViewLogs
}
}
diff --git a/seatunnel-ui/tsconfig.json b/seatunnel-ui/tsconfig.json
index 815e2c0d7..47b1695d6 100644
--- a/seatunnel-ui/tsconfig.json
+++ b/seatunnel-ui/tsconfig.json
@@ -3,20 +3,23 @@
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
- "strict": true,
+ "strict": false,
"noImplicitAny": false,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
"lib": ["esnext", "dom"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"skipLibCheck": true,
+ "allowJs": true,
"types": ["vite/client"],
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
- "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "exclude": ["node_modules", "dist"]
}
diff --git a/seatunnel-ui/vite.config.ts b/seatunnel-ui/vite.config.ts
index 8a3225a0a..6cb044cc4 100644
--- a/seatunnel-ui/vite.config.ts
+++ b/seatunnel-ui/vite.config.ts
@@ -18,35 +18,35 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
-import viteCompression from 'vite-plugin-compression'
import path from 'path'
-export default defineConfig({
- base: process.env.NODE_ENV === 'production' ? '/ui/' : '/',
- plugins: [
- vue(),
- vueJsx(),
- viteCompression({
- verbose: true,
- disable: false,
- threshold: 10240,
- algorithm: 'gzip',
- ext: '.gz',
- deleteOriginFile: false
- })
- ],
- resolve: {
- alias: {
- '@': path.resolve(__dirname, 'src'),
- 'vue-i18n': 'vue-i18n/dist/vue-i18n.cjs.js',
- '@intlify/shared': '@intlify/shared/dist/shared.cjs.js'
- }
- },
- server: {
- proxy: {
- '/seatunnel/api/v1': {
- target: loadEnv('development', './').VITE_APP_DEV_WEB_URL,
- changeOrigin: true
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+
+ return {
+ base: process.env.NODE_ENV === 'production' ? '/ui/' : '/',
+ plugins: [
+ vue(),
+ vueJsx()
+ ],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ 'vue-i18n': 'vue-i18n/dist/vue-i18n.cjs.js',
+ '@intlify/shared': '@intlify/shared/dist/shared.cjs.js'
+ }
+ },
+ server: {
+ proxy: {
+ '/seatunnel/api/v1': {
+ target: env.VITE_APP_DEV_WEB_URL || 'http://127.0.0.1:8801',
+ changeOrigin: true
+ },
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, '')
+ }
}
}
}