feat: change to old ui

This commit is contained in:
tangtaoit
2024-11-24 14:30:08 +08:00
parent 06407bd9c6
commit c019e30cf2
136 changed files with 8615 additions and 13184 deletions

View File

@@ -1,50 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2021: true
},
parser: 'vue-eslint-parser',
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
// eslint-config-prettier 的缩写
'prettier',
'vue-global-api'
],
parserOptions: {
ecmaVersion: 12,
parser: '@typescript-eslint/parser',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
// eslint-plugin-vue @typescript-eslint/eslint-plugin eslint-plugin-prettier的缩写
plugins: ['vue', '@typescript-eslint', 'prettier'],
rules: {
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-mutating-props': 'off',
'no-undef': 'off'
},
globals: {
defineProps: 'readonly',
defineEmits: 'readonly',
defineExpose: 'readonly',
withDefaults: 'readonly'
}
};

5
web/.gitignore vendored
View File

@@ -6,11 +6,8 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
yarn.lock
node_modules
dist
dist-ssr
*.local
# Editor directories and files
@@ -22,4 +19,4 @@ dist-ssr
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?

View File

@@ -1,21 +0,0 @@
module.exports = {
printWidth: 130, // 超过最大值换行
tabWidth: 2, // 缩进字节数
useTabs: false, // 缩进使用tab不使用空格
semi: true, // 句尾添加分号
singleQuote: true, // 使用单引号代替双引号
proseWrap: 'preserve', // 默认值。因为使用了一些折行敏感型的渲染器如GitHub comment而按照markdown文本样式进行折行
arrowParens: 'avoid', // (x) => {} 箭头函数参数只有一个时是否要有小括号。avoid省略括号
bracketSpacing: true, // 在对象,数组括号与文字之间加空格 "{ foo: bar }"
endOfLine: 'auto', // 结尾是 \n \r \n\r auto
jsxSingleQuote: false, // 在jsx中使用单引号代替双引号
trailingComma: 'none', // 在对象或数组最后一个元素后面是否加逗号在ES5中加尾逗号
overrides: [
{
files: '*.html',
options: {
parser: 'html'
}
}
]
};

View File

@@ -1,76 +1,18 @@
# 悟空IM后台监控
# Vue 3 + TypeScript + Vite
<a href="https://cn.vuejs.org/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/Vue-3.4.21-%236CB52D.svg?logo=Vue.js" alt="Vue">
</a> &nbsp
<a href="https://router.vuejs.org/zh/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/Vue Router-4.2.4-%236CB52D.svg?logo=VueRouter" alt="Vue Router">
</a> &nbsp
<a href="https://pinia.vuejs.org/zh/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/Pinia-2.1.6-%236CB52D.svg?logo=Pinia" alt="Pinia">
</a> &nbsp
<a href="https://ts.nodejs.cn/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/TypeScript-5.0.4-%236CB52D.svg?logo=TypeScript&logoColor=FFF" alt="TypeScript">
</a> &nbsp
<a href="https://cn.vitejs.dev/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/Vite-5.2.11-%236CB52D.svg?logo=Vite&logoColor=FFF" alt="Vite">
</a> &nbsp
<a href="https://element-plus.org/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/Element Plus-2.7.3-%236CB52D.svg?logo=ElementPlus" alt="Element Plus">
</a> &nbsp
<a href="https://unocss.dev/" target="_blank" rel="noopener" style="display:inline-block;">
<img src="https://img.shields.io/badge/UnoCss-0.51.12-%236CB52D.svg?logo=UnoCss" alt="Unocss">
</a> &nbsp
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
- NODE_ENV node环境变量
- APP_ENV 应用环境变量
## Recommended IDE Setup
## 安装
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
```sh
pnpm install
```
## Type Support For `.vue` Imports in TS
## 本地开发
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
```sh
pnpm dev
```
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
## 编译
```sh
pnpm build
```
## 本地预览
> 先执行编译再执行该命令
```sh
pnpm serve
```
## eslint检测
```sh
pnpm lint
```
## eslint修复
```sh
pnpm lint:fix
```
## prettier检测
```sh
pnpm format
```
## Git commit
```sh
pnpm cz
```
1. Disable the built-in TypeScript Extension
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.

View File

@@ -1,2 +0,0 @@
export * from './plugins';
export * from './utils';

View File

@@ -1,30 +0,0 @@
import { createHtmlPlugin } from 'vite-plugin-html';
import dayjs from 'dayjs';
import { __APP_INFO__ } from '../utils';
const t = dayjs(__APP_INFO__.PROJECT_BUILD_TIME).format('YYYYMMDDHHmmss');
const getAppConfigSrc = () => {
return `/tsdd-config.js?v=${__APP_INFO__.pkg.version}-${t}`;
};
export default function createHtml() {
const tags = [];
if (process.env.IS_CONFIG) {
tags.push({
tag: 'script',
attrs: {
src: getAppConfigSrc()
}
});
}
return createHtmlPlugin({
inject: {
data: {
title: __APP_INFO__.APP_TITLE,
__APP_INFO__: JSON.stringify(__APP_INFO__)
},
tags
}
});
}

View File

@@ -1,28 +0,0 @@
import type { PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import VueDevtools from 'vite-plugin-vue-devtools';
import unocss from '@unocss/vite';
import unplugin from './unplugin';
import createHtml from './html';
import createLayouts from './layouts';
import createPage from './pages';
/**
* vite插件
* @param viteEnv - 环境变量配置
*/
export function setupVitePlugins(): (PluginOption | PluginOption[])[] {
const plugins = [
vue(),
vueJsx(),
VueDevtools(),
...unplugin(),
unocss()
];
plugins.push(createHtml());
plugins.push(createLayouts());
plugins.push(createPage());
return plugins;
}

View File

@@ -1,7 +0,0 @@
import Layouts from 'vite-plugin-vue-meta-layouts';
export default function createLayouts() {
return Layouts({
defaultLayout: 'index'
});
}

View File

@@ -1,8 +0,0 @@
import Pages from 'vite-plugin-pages';
export default function createPage() {
return Pages({
dirs: 'src/pages',
exclude: ['**/components/*.vue']
});
}

View File

@@ -1,31 +0,0 @@
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/rollup';
import setupExtend from 'unplugin-vue-setup-extend-plus/vite';
import { VxeResolver, lazyImport } from 'vite-plugin-lazy-import';
export default function unplugin() {
return [
AutoImport({
include: [/\.[tj]sx?$/, /\.vue\?vue/, /\.md$/],
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [],
dts: 'src/types/auto-imports.d.ts'
}),
Components({
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
resolvers: [],
dts: 'src/types/components.d.ts'
}),
lazyImport({
resolvers: [
VxeResolver({
libraryName: 'vxe-table'
}),
VxeResolver({
libraryName: 'vxe-pc-ui'
})
]
}),
setupExtend({})
];
}

View File

@@ -1,29 +0,0 @@
import path from 'path';
import dayjs from 'dayjs';
import { name, version, engines } from '../../package.json';
export const __APP_INFO__ = {
pkg: { name, version, engines },
APP_TITLE: '悟空IM分布式管理系统',
APP_TITLE_SHORT: 'WK',
APP_DOCS: 'https://githubim.com/',
PROJECT_BUILD_TIME: dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss')
};
/**
* 获取项目根路径
* @descrition 末尾不带斜杠
*/
export function getRootPath() {
return path.resolve(process.cwd());
}
/**
* 获取项目src路径
* @param srcName - src目录名称(默认: "src")
* @descrition 末尾不带斜杠
*/
export function getSrcPath(srcName = 'src') {
const rootPath = getRootPath();
return `${rootPath}/${srcName}`;
}

BIN
web/bun.lockb Executable file

Binary file not shown.

1
web/dist/assets/index-035b81d4.css vendored Normal file

File diff suppressed because one or more lines are too long

345
web/dist/assets/index-6e26e10e.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
web/dist/assets/logo-9c3a9007.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

15
web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/web/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>悟空IM</title>
<script type="module" crossorigin src="/web/assets/index-6e26e10e.js"></script>
<link rel="stylesheet" href="/web/assets/index-035b81d4.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

BIN
web/dist/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
web/dist/logo_big.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1
web/dist/vite.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%- title %></title>
<title>悟空IM</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

1785
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +1,34 @@
{
"name": "wukong-im-console",
"version": "2.0.0",
"name": "wukongimmanager",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --fix --ext .ts,.tsx,.vue,.js,.jsx,",
"lint:prettier": "prettier --write src"
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@antv/x6": "^2.18.1",
"@antv/x6-vue-shape": "^2.1.2",
"@ctrl/tinycolor": "^4.1.0",
"@element-plus/icons-vue": "^2.3.1",
"@icon-park/vue-next": "^1.4.2",
"@vueuse/core": "^11.2.0",
"@vxe-ui/plugin-render-element": "^4.0.7",
"axios": "^1.7.4",
"axios": "^1.6.7",
"buffer": "^6.0.3",
"dayjs": "^1.11.12",
"echarts": "^5.5.1",
"element-plus": "^2.8.0",
"nprogress": "^0.2.0",
"pinia": "^2.2.5",
"pinia-plugin-persistedstate": "^4.1.2",
"unocss": "^0.60.4",
"vue": "^3.5.12",
"vue-i18n": "^10.0.4",
"vue-json-pretty": "^2.4.0",
"vue-router": "^4.4.3",
"vxe-pc-ui": "^4.2.37",
"vxe-table": "^4.7.97",
"xe-utils": "^3.5.31"
"echarts": "^5.5.0",
"vue": "^3.2.47",
"vue-json-pretty": "^2.3.0",
"vue-router": "^4.3.0",
"vue3-cookies": "^1.0.6"
},
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/node": "^20.14.10",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@unocss/preset-uno": "^0.60.4",
"@unocss/vite": "^0.60.4",
"@vitejs/plugin-vue": "^5.1.2",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.27.0",
"sass": "^1.80.6",
"typescript": "^5.5.4",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"unplugin-vue-router": "^0.10.8",
"unplugin-vue-setup-extend-plus": "^1.0.0",
"vite": "^5.4.10",
"vite-plugin-banner": "^0.7.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-html": "^3.2.2",
"vite-plugin-html-template": "^1.2.2",
"vite-plugin-lazy-import": "^1.0.7",
"vite-plugin-pages": "^0.32.3",
"vite-plugin-vue-devtools": "^7.3.7",
"vite-plugin-vue-meta-layouts": "^0.4.3",
"vue-global-api": "^0.4.1",
"vue-tsc": "^1.8.27"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0",
"pnpm": ">=9.0.0"
"@vitejs/plugin-vue": "^4.1.0",
"autoprefixer": "^10.4.18",
"daisyui": "latest",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.0.2",
"vite": "^4.3.2",
"vue-tsc": "^1.4.2"
}
}

5424
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
web/public/logo_big.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,41 +1,368 @@
<script lang="ts" setup>
import { getBrowserLang } from '@/utils';
import { useGlobalStore } from '@/stores/modules/global';
<script setup lang="ts">
import { LanguageType } from '@/stores/interface';
import { useTheme } from '@/hooks/useTheme';
import { useI18n } from 'vue-i18n';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';
// init theme
const { initTheme } = useTheme();
initTheme();
const globalStore = useGlobalStore();
const locale = computed(() => {
if (globalStore.language == 'zh') return zhCn;
if (globalStore.language == 'en') return en;
return getBrowserLang() == 'zh' ? zhCn : en;
});
const assemblySize = computed(() => globalStore.assemblySize);
const buttonConfig = reactive({ autoInsertSpace: false });
const i18n = useI18n();
import { onMounted } from 'vue';
import Login from './pages/login/Login.vue';
import App from './services/App';
import APIClient from './services/APIClient';
onMounted(() => {
const language = globalStore.language ?? getBrowserLang();
i18n.locale.value = language;
globalStore.setGlobalState('language', language as LanguageType);
if(App.shard().loginInfo.isLogin()) {
App.shard().loadSystemSettingIfNeed()
}
APIClient.shared.config.tokenCallback = () => {
return App.shard().loginInfo.token
}
APIClient.shared.logoutCallback = () => {
App.shard().loginInfo.clear();
window.location.href = '/web/login'
}
});
const onLogout = () => {
console.log('logout')
App.shard().loginInfo.clear();
window.location.href = '/web/login'
}
</script>
<template>
<el-config-provider :locale="locale" :size="assemblySize" :button="buttonConfig">
<router-view></router-view>
</el-config-provider>
<div class="flex-col min-h-screen w-screen" v-if="App.shard().loginInfo.isLogin()">
<!-- nav -->
<div class="h-[4rem] w-full flex items-center fixed justify-end">
<div class="h-[4rem] w-full flex items-center">
<img src="./assets/logo.png" class="w-10 h-10 ml-[2rem] mt-1">
<div class="font-bold text-xl ml-2">悟空IM</div>
</div>
<div class="mr-20 flex">
<label class="mt-[0.4rem] mr-2 text-sm">{{App.shard().loginInfo.username}}</label>
<button class="btn btn-sm" v-on:click="()=>onLogout()">
<svg width="18" height="18" viewBox="0 0 48 48" fill="none">
<path d="M23.9917 6H6V42H24" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M33 33L42 24L33 15" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M16 23.9917H42" stroke="#333" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
<main class="flex pt-[4rem] w-full min-h-screen">
<!-- left -->
<div class="flex-col w-80 h-full z-50">
<!-- menus -->
<!-- <div class="w-full h-full flex-col justify-start text-left pt-5">
<ul class="menu w-56 rounded-box">
<li>
<RouterLink to="/cluster/nodes">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18V42H39V18L24 6L9 18Z" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M19 29V42H29V29H19Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path d="M9 42H39" stroke="#333" stroke-width="2" stroke-linecap="round" />
</svg>
<label class="ml-2">首页</label>
</RouterLink>
</li>
</ul>
</div> -->
<!-- cluster -->
<div class="w-full h-full flex-col justify-start text-left pt-5">
<div class="w-full flex-col">
<div class="w-full flex pl-6">
<label class="text-[1.1rem] font-semibold">分布式</label>
</div>
<ul className="menu w-56 rounded-box">
<li>
<RouterLink to="/cluster/nodes">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="34" width="8" height="8" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<rect x="8" y="6" width="32" height="12" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M24 34V18" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 34V26H40V34" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<rect x="36" y="34" width="8" height="8" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<rect x="20" y="34" width="8" height="8" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 12H16" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">节点</label>
</RouterLink>
</li>
<li>
<RouterLink to="/cluster/slots">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<label class="ml-2">分区</label>
</RouterLink>
</li>
<li>
<RouterLink to="/cluster/channels">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<label class="ml-2">频道</label>
</RouterLink>
</li>
<li>
<RouterLink to="/cluster/config">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M41.5 10H35.5" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M27.5 6V14" stroke="#333" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M27.5 10L5.5 10" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M13.5 24H5.5" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M21.5 20V28" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M43.5 24H21.5" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M41.5 38H35.5" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M27.5 34V42" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M27.5 38H5.5" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<label class="ml-2">配置</label>
</RouterLink>
</li>
</ul>
</div>
</div>
<!-- <div class="w-full bg-gray-100 mt-0" style="height: 1px;"></div> -->
<div class="w-full h-full flex-col justify-start text-left pt-5">
<div class="w-full flex-col">
<div class="w-full flex pl-6">
<label class="text-[1.1rem] font-bold">数据</label>
</div>
<ul className="menu w-56 rounded-box">
<li>
<RouterLink to="/data/connection">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M37 22.0001L34 25.0001L23 14.0001L26 11.0001C27.5 9.50002 33 7.00005 37 11.0001C41 15.0001 38.5 20.5 37 22.0001Z"
fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M42 6L37 11" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M11 25.9999L14 22.9999L25 33.9999L22 36.9999C20.5 38.5 15 41 11 36.9999C7 32.9999 9.5 27.5 11 25.9999Z"
fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M23 32L27 28" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M6 42L11 37" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M16 25L20 21" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<label class="ml-2">连接Top100</label>
</RouterLink>
</li>
<li>
<RouterLink to="/data/user">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="12" r="8" fill="none" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M42 44C42 34.0589 33.9411 26 24 26C14.0589 26 6 34.0589 6 44" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">用户</label>
</RouterLink>
</li>
<li>
<RouterLink to="/data/device">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23 43H43V5H14V15" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path d="M5 15H23V43H5L5 15Z" fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path d="M13 37H15" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M28 37H30" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">设备</label>
</RouterLink>
</li>
<li>
<RouterLink to="/data/message">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M44 24C44 35.0457 35.0457 44 24 44C18.0265 44 4 44 4 44C4 44 4 29.0722 4 24C4 12.9543 12.9543 4 24 4C35.0457 4 44 12.9543 44 24Z"
fill="none" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M13.9999 26L20 32L33 19" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<label class="ml-2">消息</label>
</RouterLink>
</li>
<li>
<RouterLink to="/data/channel">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<label class="ml-2">频道</label>
</RouterLink>
</li>
<li>
<RouterLink to="/data/conversation">
<svg width="20" height="20" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44 6H4V36H13V41L23 36H44V6Z" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 21H34" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">会话</label>
</RouterLink>
</li>
</ul>
</div>
</div>
<div class="w-full h-full flex-col justify-start text-left pt-5">
<div class="w-full flex-col">
<div class="w-full flex pl-6">
<label class="text-[1.1rem] font-bold">监控</label>
</div>
<ul className="menu w-56 rounded-box">
<li>
<RouterLink to="/monitor/app">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44 5H3.99998V17H44V5Z" fill="none" stroke="#333" stroke-width="2"
stroke-linejoin="round" />
<path d="M3.99998 41.0301L16.1756 28.7293L22.7549 35.0301L30.7982 27L35.2786 31.368" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M44 16.1719V42.1719" stroke="#333" stroke-width="2" stroke-linecap="round" />
<path d="M3.99998 16.1719V30.1719" stroke="#333" stroke-width="2" stroke-linecap="round" />
<path d="M13.0155 43H44" stroke="#333" stroke-width="2" stroke-linecap="round" />
<path d="M17 11H38" stroke="#333" stroke-width="2" stroke-linecap="round" />
<path d="M9.99998 10.9966H11" stroke="#333" stroke-width="2" stroke-linecap="round" />
</svg>
<label class="ml-2">应用</label>
</RouterLink>
</li>
<li>
<RouterLink to="/monitor/system">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z"
fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path d="M11 28.1321H16.6845L21.2234 13L24.8953 35L29.4483 24.6175L32.9127 28.1321H37" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">系统</label>
</RouterLink>
</li>
<li>
<RouterLink to="/monitor/cluster">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M43 4H5C4.44772 4 4 4.48842 4 5.09091V14.9091C4 15.5116 4.44772 16 5 16H43C43.5523 16 44 15.5116 44 14.9091V5.09091C44 4.48842 43.5523 4 43 4Z"
fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path
d="M43 32H5C4.44772 32 4 32.4884 4 33.0909V42.9091C4 43.5116 4.44772 44 5 44H43C43.5523 44 44 43.5116 44 42.9091V33.0909C44 32.4884 43.5523 32 43 32Z"
fill="none" stroke="#333" stroke-width="2" stroke-linejoin="round" />
<path d="M14 16V24.0083L34 24.0172V32" stroke="#333" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M18 38H30" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M18 10H30" stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">分布式</label>
</RouterLink>
</li>
<li>
<RouterLink to="/monitor/trace">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M44.0001 11C44.0001 11 44 36.0623 44 38C44 41.3137 35.0457 44 24 44C12.9543 44 4.00003 41.3137 4.00003 38C4.00003 36.1423 4 11 4 11"
stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M44 29C44 32.3137 35.0457 35 24 35C12.9543 35 4 32.3137 4 29" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M44 20C44 23.3137 35.0457 26 24 26C12.9543 26 4 23.3137 4 20" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<ellipse cx="24" cy="10" rx="20" ry="6" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">消息追踪</label>
</RouterLink>
</li>
<!-- <li>
<RouterLink to="/monitor/logs">
<svg class="h-5 w-5" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M44.0001 11C44.0001 11 44 36.0623 44 38C44 41.3137 35.0457 44 24 44C12.9543 44 4.00003 41.3137 4.00003 38C4.00003 36.1423 4 11 4 11"
stroke="#333" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M44 29C44 32.3137 35.0457 35 24 35C12.9543 35 4 32.3137 4 29" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M44 20C44 23.3137 35.0457 26 24 26C12.9543 26 4 23.3137 4 20" stroke="#333"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<ellipse cx="24" cy="10" rx="20" ry="6" fill="none" stroke="#333" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
<label class="ml-2">日志</label>
</RouterLink>
</li> -->
</ul>
</div>
</div>
</div>
<!-- right -->
<div class="flex w-full pr-20 pt-2 relative">
<div class="rounded content min-w-full max-w-[60rem] overflow-hidden h-[calc(100vh-4rem)]">
<RouterView class="h-[100%]" />
</div>
</div>
</main>
</div>
<div class="flex-col min-h-screen w-screen table" v-else>
<div class="table-cell align-middle">
<Login class="h-[100%]" />
</div>
</div>
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

View File

@@ -1,106 +0,0 @@
import { useUserStore } from '@/stores/modules/user';
import { LOGIN_URL, WK_CONFIG } from '@/config';
import router from '@/router';
import { ResultEnum } from '@/enums/http-enum';
import axios from 'axios';
import { ElMessage } from 'element-plus';
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
export interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
isSaas?: boolean;
}
const userStore = useUserStore();
const axiosConfig = {
baseURL: WK_CONFIG.API_URL, // 默认地址请求地址
timeout: ResultEnum.TIMEOUT as number, // 设置超时时间
withCredentials: false // 跨域请求时是否需要使用凭证
};
class RequestHttp {
service: AxiosInstance;
public constructor(config: AxiosRequestConfig) {
this.service = axios.create(config);
/**
* @description 请求拦截器
* 客户端发送请求 -> [请求拦截器] -> 服务器
* token校验(JWT) : 接受服务器返回的 token,存储到 pinia/本地储存当中
*/
this.service.interceptors.request.use(
(config: CustomAxiosRequestConfig) => {
// 添加token
if (userStore.token) {
config.headers['Authorization'] = 'Bearer ' + userStore.token;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
/**
* @description 响应拦截器
* 服务器换返回信息 -> [拦截统一处理] -> 客户端JS获取到信息
*/
this.service.interceptors.response.use(
(response: AxiosResponse & { config: CustomAxiosRequestConfig }) => {
const { data } = response;
return Promise.resolve(data);
},
(error: any) => {
const code = error.response.status;
if (code == 401) {
userStore.setToken('');
userStore.setUserInfo({ username: '您好,超管' });
ElMessage({
message: '登录失效,请重新登录!',
grouping: true,
plain: true,
type: 'error'
});
return router.replace(LOGIN_URL);
}
if (code == 400) {
ElMessage({
message: error.response.data.msg,
grouping: true,
plain: true,
type: 'error'
});
return Promise.reject(error.response.data);
}
// 响应失败
return Promise.reject(error);
}
);
}
get<T>(url: string, params?: object, _object = {}): Promise<T> {
return this.service.get(url, { params, ..._object });
}
post<T>(url: string, params?: object | string, _object = {}): Promise<T> {
return this.service.post(url, params, _object);
}
put<T>(url: string, params?: object, _object = {}): Promise<T> {
return this.service.put(url, params, _object);
}
delete<T>(url: string, params?: any, _object = {}): Promise<T> {
return this.service.delete(url, { params, ..._object });
}
download(url: string, params?: object, _object = {}): Promise<BlobPart> {
return this.service.post(url, params, { ..._object, responseType: 'blob' });
}
}
export default new RequestHttp(axiosConfig);

View File

@@ -1,10 +0,0 @@
// 请求响应参数不包含data
export interface Result {
code: number;
msg: string;
}
// 请求响应参数包含data
export interface ResultData<T = any> extends Result {
result: T;
}

View File

@@ -1,89 +0,0 @@
import http from '@/api';
export const clusterApi = {
/**
* 获取集群节点信息
*/
nodes: () => {
return http.get<any>(`/cluster/nodes`);
},
/**
* 获取所有槽
*/
slots: () => {
return http.get<any>(`/cluster/allslot`);
},
/**
* 迁移槽
* @param param
*/
migrateSlot: (param: { slot: number; migrateFrom: number; migrateTo: number }) => {
return http.post<any>(`/cluster/slots/${param.slot}/migrate`, {
migrate_from: param.migrateFrom,
migrate_to: param.migrateTo
});
},
/**
* 获取简单节点信息
*/
simpleNodes: () => {
return http.get<any>(`/cluster/simpleNodes`);
},
/**
* 获取节点的频道配置列表
*/
nodeChannelConfigs: (param: any) => {
return http.get<any>(`/cluster/nodes/${param.nodeId}/channels`, param);
},
/**
* 迁移频道
* @param param
*/
migrateChannel: (param: { channelId: string; channelType: number; migrateFrom: number; migrateTo: number }) => {
return http.post<any>(`/cluster/channels/${param.channelId}/${param.channelType}/migrate`, {
migrate_from: param.migrateFrom,
migrate_to: param.migrateTo
});
},
/**
* 获取频道分布式配置
* @param param
*/
channelClusterConfig: (param: { channelId: string; channelType: number; nodeId?: number }) => {
return http.get(`/cluster/channels/${param.channelId}/${param.channelType}/config?node_id=${param.nodeId || 0}`);
},
/**
* 开启频道
* @param param
*/
channelStart: (param: { channelId: string; channelType: number; nodeId?: number }) => {
return http.post<any>(`/cluster/channels/${param.channelId}/${param.channelType}/start`, { node_id: param.nodeId });
},
/**
* 停止频道
* @param param
*/
channelStop: (param: { channelId: string; channelType: number; nodeId?: number }) => {
return http.post<any>(`/cluster/channels/${param.channelId}/${param.channelType}/stop`, { node_id: param.nodeId });
},
/**
* 频道副本列表
* @param param
*/
channelReplicas: (param: { channelId: string; channelType: number }) => {
return http.get<any>(`/cluster/channels/${param.channelId}/${param.channelType}/replicas`);
},
/**
* 获取集群配置
*/
clusterConfig: () => {
return http.get<any>(`/cluster/info`);
},
/**
* 获取日志
* @param param
*/
clusterLogs: (param: any) => {
return http.get<any>(`/cluster/logs`, param);
}
};

View File

@@ -1,71 +0,0 @@
import http from '@/api';
export const dataApi = {
/**
* 获取连接列表
* @param param
*/
connections: (param: any) => {
return http.get<any>(`/connz`, param);
},
/**
* 搜索用户
* @param param
*/
users: (param: any) => {
return http.get<any>(`/cluster/users`, param);
},
/**
* 获取用户的设备列表
* @param param
*/
devices: (param: any) => {
return http.get<any>(`/cluster/devices`, param);
},
/**
* 搜索消息
* @param param
*/
searchMessages: (param: any) => {
return http.get<any>(`/cluster/messages`, param);
},
/**
* 搜索频道
* @param param
*/
searchChannels: (param: any) => {
return http.get<any>(`/cluster/channels`, param);
},
/**
* 获取频道订阅者
* @param channelId
* @param channelType
*/
subscribers(channelId: string, channelType: number) {
return http.get<any>(`/cluster/channels/${channelId}/${channelType}/subscribers`);
},
/**
* 获取频道白名单列表
* @param channelId
* @param channelType
*/
allowlist(channelId: string, channelType: number) {
return http.get<any>(`/cluster/channels/${channelId}/${channelType}/allowlist`);
},
/**
* 获取频道黑名单列表
* @param channelId
* @param channelType
*/
denylist(channelId: string, channelType: number) {
return http.get<any>(`/cluster/channels/${channelId}/${channelType}/denylist`);
},
/**
* 获取用户的会话列表
* @param param
*/
conversations: (param: any) => {
return http.get<any>(`/cluster/conversations`, param);
}
};

View File

@@ -1,13 +0,0 @@
import http from '@/api';
import type { ResultData } from '@/api/interface';
export const loginApi = {
/**
* 登录
* @param param
*/
login: (param: any) => {
return http.post<ResultData>(`/manager/login`, param);
}
};

View File

@@ -1,34 +0,0 @@
import http from '@/api';
export const monitorApi = {
/**
* 获取app监控数据
*/
apppMetrics: (param: any) => {
return http.get<any>('/metrics/app', param);
},
/**
* 获取分布式监控数据
*/
clusterMetrics: (param: any) => {
return http.get<any>('/metrics/cluster', param);
},
/**
* 获取系统监控数据
*/
systemMetrics: (param: any) => {
return http.get<any>('/metrics/system', param);
},
/**
* 获取消息轨迹日志
*/
messageTraces: (param: any) => {
return http.get<any>('/cluster/message/trace', param);
},
/**
* 消息回执轨迹日志
*/
messageRecvackTraces: (param: any) => {
return http.get<any>('/cluster/message/trace/recvack', param);
}
};

View File

@@ -1,13 +0,0 @@
import http from '@/api';
import type { ResultData } from '@/api/interface';
export const systemApi = {
/**
* 获取系统设置
* @param param
*/
systemSettings: () => {
return http.get<ResultData>(`/varz/setting`);
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { onMounted, ref, watchEffect } from 'vue';
import * as echarts from "echarts";
import { formatDate, formatNumber } from '../services/Utils';
import { Series } from '../services/Model';
const props = defineProps({
data: {
type: Array<Series>,
required: true
},
title: {
type: String,
}
})
const chartElement = ref<any>(null);
const option = ref({
title: {
text: props.title || '',
textStyle: {
fontWeight: 'normal',
fontSize: 12
}
},
tooltip: {
show: true,
trigger: 'axis',
formatter: function (params: any) {
var res = formatDate(new Date(params[0].name * 1000)) + '<br/>'
for (var i = 0, length = params.length; i < length; i++) {
res += params[i].marker + params[i].seriesName + ''
+ formatNumber(params[i].value) + '<br/>'
}
return res
}
},
legend: new Array<any>(),
grid: {
left: "1%",
top: "15%",
bottom: "15%",
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
axisLabel: {
formatter: function (value: number) {
const date = new Date(value * 1000);
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
return hours + ':' + minutes + ':' + seconds;
}
},
data: new Array<number>(),
},
yAxis: {
boundaryGap: [0, '50%'],
type: 'value',
minInterval: 1,
axisLabel: {
formatter: function (value: number) {
return formatNumber(value,0);
}
}
},
series: [
{
data: new Array<number>()
},
],
});
const updateData = async (data: Series[]) => {
const series: any = []
const legendNames: any = []
option.value.xAxis.data = new Array<number>()
for (let i = 0; i < data.length; i++) {
const value = data[i];
series.push({
name: value.name,
type: value.type || 'line',
symbol: 'none',
data: new Array<number>(),
})
legendNames.push(value.name || '')
}
option.value.series = series
option.value.legend = [{
data: legendNames,
bottom: '0%',
textStyle: {
fontSize: 12,
},
}]
for (let i = 0; i < data.length; i++) {
const value = data[i];
for (let j = 0; j < value.data.length; j++) {
const point = value.data[j]
if (i == 0) {
option.value.xAxis.data.push(point.timestamp || 0);
}
option.value.series[i].data.push(point.value);
}
}
chart?.setOption(option.value);
}
const refreshChart = () => {
chart?.setOption(option.value);
}
let chart: echarts.ECharts
// 待组件挂载完成后初始化 ECharts
onMounted(() => {
chart = echarts.init(chartElement.value);
chart.setOption(option.value);
// startRealtimeData();
updateData(props.data)
refreshChart()
window.addEventListener('resize', () => chart.resize())
})
watchEffect(() => {
updateData(props.data)
refreshChart()
});
</script>
<template>
<div class="h-full w-full" ref="chartElement"></div>
</template>

View File

@@ -0,0 +1,53 @@
<template>
<div class="w-full h-full">
<table class="table table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>
<div class="flex items-center">
连接ID
</div>
</th>
<th>
<div class="flex items-center">
用户UID
</div>
</th>
<th>
<div class="flex items-center">
设备ID
</div>
</th>
<th>
<div class="flex items-center">
收到时间
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in props.data?.data">
<td>{{ item.conn_id }}</td>
<td>{{ item.uid }}</td>
<td>{{ item.device_id }}</td>
<td>{{ item.time }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
data: {
type: Object,
required: true
},
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="w-full h-full border border-blue-500 rounded-md shadow-md grid grid-rows-3 grid-cols-[auto,1fr] bg-white">
<div class="row-span-3 p-2" v-html="data.icon"></div>
<div class="font-semibold flex items-center text-[0.9rem] justify-between">
<div>{{data.name}}</div>
</div>
<div class="text text-sm text-gray-400 flex items-center">{{data.description}}</div>
<div class="text text-sm text-gray-400 flex items-center">{{data.time}}</div>
</div>
</template>
<script setup lang="ts">
import { inject, onMounted,ref } from 'vue';
const getNode = inject<() => any>('getNode');
const data = ref({name: '', time: '',icon:'',duration:0,description:''});
onMounted(() => {
const node = getNode!();
data.value = node.data;
});
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div class="w-full h-full border border-blue-500 rounded-md shadow-md bg-white">
<div class="w-full h-full font-semibold flex items-center text-[0.9rem] justify-center">
<div>{{data.name}}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { inject, onMounted,ref } from 'vue';
const getNode = inject<() => any>('getNode');
const data = ref({name: ''});
onMounted(() => {
const node = getNode!();
data.value = node.data;
});
</script>

View File

@@ -1,138 +0,0 @@
<template>
<div class="h-full w-full" ref="chartElement"></div>
</template>
<script lang="ts" name="WkMonitorPanel" setup>
import { onMounted, ref, watchEffect } from 'vue';
import * as echarts from 'echarts';
import { formatDate, formatNumber } from '@/utils';
const props = defineProps({
data: {
type: Array<any>,
required: true
},
title: {
type: String
}
});
const chartElement = ref<any>(null);
const option = ref({
title: {
text: props.title || '',
textStyle: {
fontWeight: 'normal',
fontSize: 12
}
},
tooltip: {
show: true,
trigger: 'axis',
formatter: function (params: any) {
let res = formatDate(new Date(params[0].name * 1000)) + '<br/>';
for (let i = 0, length = params.length; i < length; i++) {
res += params[i].marker + params[i].seriesName + '' + formatNumber(params[i].value) + '<br/>';
}
return res;
}
},
legend: new Array<any>(),
grid: {
left: '1%',
top: '15%',
bottom: '15%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
axisLabel: {
formatter: function (value: number) {
const date = new Date(value * 1000);
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
return hours + ':' + minutes + ':' + seconds;
}
},
data: new Array<number>()
},
yAxis: {
boundaryGap: [0, '50%'],
type: 'value',
minInterval: 1,
axisLabel: {
formatter: function (value: number) {
return formatNumber(value, 0);
}
}
},
series: [
{
data: new Array<number>()
}
]
});
const updateData = async (data: any[]) => {
const series: any = [];
const legendNames: any = [];
option.value.xAxis.data = new Array<number>();
for (let i = 0; i < data.length; i++) {
const value = data[i];
series.push({
name: value.name,
type: value.type || 'line',
symbol: 'none',
data: new Array<number>()
});
legendNames.push(value.name || '');
}
option.value.series = series;
option.value.legend = [
{
data: legendNames,
bottom: '0%',
textStyle: {
fontSize: 12
}
}
];
for (let i = 0; i < data.length; i++) {
const value = data[i];
for (let j = 0; j < value.data.length; j++) {
const point = value.data[j];
if (i == 0) {
option.value.xAxis.data.push(point.timestamp || 0);
}
option.value.series[i].data.push(point.value);
}
}
chart?.setOption(option.value);
};
const refreshChart = () => {
chart?.setOption(option.value);
};
let chart: echarts.ECharts;
// 待组件挂载完成后初始化 ECharts
onMounted(() => {
chart = echarts.init(chartElement.value);
chart.setOption(option.value);
// startRealtimeData();
updateData(props.data);
refreshChart();
window.addEventListener('resize', () => chart.resize());
});
watchEffect(() => {
updateData(props.data);
refreshChart();
});
</script>

View File

@@ -1,15 +0,0 @@
<template>
<div class="wk-page">
<slot />
</div>
</template>
<script lang="ts" name="WkPage" setup></script>
<style lang="scss" scoped>
.wk-page {
width: 100%;
height: 100%;
padding: 12px;
box-sizing: border-box;
}
</style>

View File

@@ -1,22 +0,0 @@
// 首页地址(默认)
export const HOME_URL = '/cluster/nodes';
// 登录页地址(默认)
export const LOGIN_URL = '/login';
// 默认主题颜色
export const DEFAULT_PRIMARY = '#DF5D2A';
// 命名空间
export const NAME_SPACE = 'wk';
// 路由白名单地址(必须是本地存在的路由 static-router.ts 中)
export const ROUTER_WHITE_LIST: string[] = ['/login'];
// 默认应用配置
export const WK_CONFIG = {
APP_TITLE: '悟空IM分布式',
APP_TITLE_SHORT: 'IM',
APP_TITLE_LOGIN: 'WuKongIM分布式管理系统',
API_URL: 'http://monitor.githubim.com'
};

View File

@@ -1,30 +0,0 @@
interface IItem {
value: number;
label: string;
}
export const CHANNEL_TYPE: IItem[] = [
{ value: 1, label: '个人' },
{ value: 2, label: '群聊' },
{ value: 3, label: '客服' },
{ value: 4, label: '社区' },
{ value: 5, label: '话题' },
{ value: 6, label: '资讯' },
{ value: 7, label: '数据' }
];
export const LATEST_TIME: IItem[] = [
{ value: 300, label: '过去5分钟' },
{ value: 1800, label: '过去30分钟' },
{ value: 3600, label: '过去1小时' },
{ value: 21600, label: '过去6小时' },
{ value: 86400, label: '过去1天' },
{ value: 259200, label: '过去3天' },
{ value: 604800, label: '过去7天' }
];
export const APP_DOC = 'https://githubim.com';
export const APP_ISSUES = 'https://github.com/WuKongIM/WuKongIM/issues';
export const ActionWrite = 'w';
export const ActionRead = 'r';

View File

@@ -1,35 +0,0 @@
/**
* @description请求配置
*/
export enum ResultEnum {
SUCCESS = 200,
ERROR = 500,
OVERDUE = 501,
TIMEOUT = 30000,
TYPE = 'success'
}
/**
* @description请求方法
*/
export enum RequestEnum {
GET = 'GET',
POST = 'POST',
PATCH = 'PATCH',
PUT = 'PUT',
DELETE = 'DELETE'
}
/**
* @description常用的 contentTyp 类型
*/
export enum ContentTypeEnum {
// json
JSON = 'application/json;charset=UTF-8',
// text
TEXT = 'text/plain;charset=UTF-8',
// form-data 一般配合qs
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
// form-data 上传
FORM_DATA = 'multipart/form-data;charset=UTF-8'
}

View File

@@ -1,34 +0,0 @@
export namespace Table {
export interface Pageable {
pageNum: number;
pageSize: number;
total: number;
}
export interface TableStateProps {
tableData: any[];
pageable: Pageable;
searchParam: {
[key: string]: any;
};
searchInitParam: {
[key: string]: any;
};
totalParam: {
[key: string]: any;
};
icon?: {
[key: string]: any;
};
}
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace HandleData {
export type MessageType = '' | 'success' | 'warning' | 'info' | 'error';
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Theme {
export type ThemeType = 'light' | 'inverted' | 'dark';
export type GreyOrWeakType = 'grey' | 'weak';
}

View File

@@ -1,122 +0,0 @@
import { DEFAULT_PRIMARY } from '@/config';
import { useGlobalStore } from '@/stores/modules/global';
import {getDarkColor, getLightColor, convertToHslCssVar} from '@/utils/color';
import { menuTheme } from '@/styles/theme/menu';
import { asideTheme } from '@/styles/theme/aside';
import { headerTheme } from '@/styles/theme/header';
import { storeToRefs } from 'pinia';
import { ElMessage } from 'element-plus';
import type { Theme } from './interface';
/**
* @description 全局主题 hooks
* */
export const useTheme = () => {
const globalStore = useGlobalStore();
const { primary, isDark, isGrey, isWeak, asideInverted, layout, headerInverted } = storeToRefs(globalStore);
// 切换暗黑模式 ==> 并带修改主题颜色、侧边栏颜色
const switchDark = () => {
const html = document.documentElement as HTMLElement;
if (isDark.value) {
html.setAttribute('class', 'dark');
html.setAttribute('data-vxe-ui-theme', 'dark');
html.setAttribute('vxe-docs-theme', 'dark');
} else {
html.setAttribute('class', '');
html.setAttribute('data-vxe-ui-theme', 'light');
html.setAttribute('vxe-docs-theme', 'light');
}
changePrimary(primary.value);
setAsideTheme();
setHeaderTheme();
};
// 修改主题颜色
const changePrimary = (val: any | null) => {
if (!val) {
val = DEFAULT_PRIMARY;
ElMessage({ type: 'success', message: `主题颜色已重置为 ${DEFAULT_PRIMARY}` });
}
val = val.hex ? val.hex : val;
// 计算主题颜色变化
document.documentElement.style.setProperty('--el-color-primary', val);
document.documentElement.style.setProperty(
'--el-color-primary-dark-2',
isDark.value ? `${getLightColor(val, 0.2)}` : `${getDarkColor(val, 0.3)}`
);
for (let i = 1; i <= 9; i++) {
const primaryColor = isDark.value ? `${getDarkColor(val, i / 10)}` : `${getLightColor(val, i / 10)}`;
document.documentElement.style.setProperty(`--el-color-primary-light-${i}`, primaryColor);
}
document.documentElement.style.setProperty(`--primary`, convertToHslCssVar(val))
globalStore.setGlobalState('primary', val);
};
// 灰色和弱色切换
const changeGreyOrWeak = (type: Theme.GreyOrWeakType, value: boolean) => {
const body = document.body as HTMLElement;
if (!value) return body.removeAttribute('style');
const styles: Record<Theme.GreyOrWeakType, string> = {
grey: 'filter: grayscale(1)',
weak: 'filter: invert(80%)'
};
body.setAttribute('style', styles[type]);
const propName = type === 'grey' ? 'isWeak' : 'isGrey';
globalStore.setGlobalState(propName, false);
};
// 设置菜单样式
const setMenuTheme = () => {
let type: Theme.ThemeType = 'light';
if (layout.value === 'transverse' && headerInverted.value) type = 'inverted';
if (layout.value !== 'transverse' && asideInverted.value) type = 'inverted';
if (isDark.value) type = 'dark';
const theme = menuTheme[type!];
for (const [key, value] of Object.entries(theme)) {
document.documentElement.style.setProperty(key, value);
}
};
// 设置侧边栏样式
const setAsideTheme = () => {
let type: Theme.ThemeType = 'light';
if (asideInverted.value) type = 'inverted';
if (isDark.value) type = 'dark';
const theme = asideTheme[type!];
for (const [key, value] of Object.entries(theme)) {
document.documentElement.style.setProperty(key, value);
}
setMenuTheme();
};
// 设置头部样式
const setHeaderTheme = () => {
let type: Theme.ThemeType = 'light';
if (headerInverted.value) type = 'inverted';
if (isDark.value) type = 'dark';
const theme = headerTheme[type!];
for (const [key, value] of Object.entries(theme)) {
document.documentElement.style.setProperty(key, value);
}
setMenuTheme();
};
// init theme
const initTheme = () => {
switchDark();
if (isGrey.value) changeGreyOrWeak('grey', true);
if (isWeak.value) changeGreyOrWeak('weak', true);
};
return {
initTheme,
switchDark,
changePrimary,
changeGreyOrWeak,
setAsideTheme,
setHeaderTheme
};
};

View File

@@ -1,18 +0,0 @@
import { getBrowserLang } from '@/utils'
import { createI18n } from 'vue-i18n'
import zh from './modules/zh'
import en from './modules/en'
const i18n = createI18n({
allowComposition: true,
legacy: false,
locale: getBrowserLang(),
messages: {
zh,
en
}
})
export default i18n

View File

@@ -1,28 +0,0 @@
export default {
home: {
welcome: 'Welcome'
},
tabs: {
more: 'More',
refresh: 'Refresh',
maximize: 'Maximize',
closeCurrent: 'Close current',
closeOther: 'Close other',
closeAll: 'Close All'
},
header: {
componentSize: 'Component size',
language: 'Language',
theme: 'theme',
layoutConfig: 'Layout config',
primary: 'primary',
darkMode: 'Dark Mode',
greyMode: 'Grey mode',
weakMode: 'Weak mode',
fullScreen: 'Full Screen',
exitFullScreen: 'Exit Full Screen',
personalData: 'Personal Data',
changePassword: 'Change Password',
logout: 'Logout'
}
};

View File

@@ -1,28 +0,0 @@
export default {
home: {
welcome: '欢迎使用'
},
tabs: {
more: '更多',
refresh: '刷新',
maximize: '最大化',
closeCurrent: '关闭当前',
closeOther: '关闭其它',
closeAll: '关闭所有'
},
header: {
componentSize: '组件大小',
language: '国际化',
theme: '全局主题',
layoutConfig: '布局设置',
primary: 'primary',
darkMode: '暗黑模式',
greyMode: '灰色模式',
weakMode: '色弱模式',
fullScreen: '全屏',
exitFullScreen: '退出全屏',
personalData: '个人信息',
changePassword: '修改密码',
logout: '退出登录'
}
};

View File

@@ -1,35 +0,0 @@
<script lang="ts" name="Main" setup>
import { storeToRefs } from 'pinia';
import { useGlobalStore } from '@/stores/modules/global';
const globalStore = useGlobalStore();
const { layout } = storeToRefs(globalStore);
// 监听布局变化,在 body 上添加相对应的 layout class
watch(
() => layout.value,
() => {
const body = document.body as HTMLElement;
body.setAttribute('class', layout.value);
},
{ immediate: true }
);
</script>
<template>
<el-main>
<router-view v-slot="{ Component, route }">
<transition appear name="fade-transform" mode="out-in">
<component :is="Component" :key="route.fullPath" />
</transition>
</router-view>
</el-main>
</template>
<style lang="scss" scoped>
.el-main {
box-sizing: border-box;
padding: 0;
overflow-x: hidden;
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,21 +0,0 @@
<script name="ToolBarLeft" lang="ts" setup>
import { useGlobalStore } from '@/stores/modules/global';
import Breadcrumb from './components/Breadcrumb.vue';
const globalStore = useGlobalStore();
</script>
<template>
<div class="tool-bar-lf">
<Breadcrumb v-if="globalStore.breadcrumb" id="breadcrumb" />
</div>
</template>
<style scoped lang="scss">
.tool-bar-lf {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
white-space: nowrap;
}
</style>

View File

@@ -1,26 +0,0 @@
<script name="ToolBarRight" lang="ts" setup>
import Avatar from './components/Avatar.vue';
</script>
<template>
<div class="tool-bar-ri">
<div class="header-icon"></div>
<Avatar id="avatar" />
</div>
</template>
<style scoped lang="scss">
.tool-bar-ri {
display: flex;
align-items: center;
justify-content: center;
.header-icon {
display: flex;
align-items: center;
& > * {
margin-left: 20px;
color: var(--el-header-text-color);
}
}
}
</style>

View File

@@ -1,111 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/modules/user';
import { LOGIN_URL } from '@/config';
import { APP_DOC, APP_ISSUES } from '@/constants';
const router = useRouter();
const userStore = useUserStore();
const username = computed(() => {
return userStore.userInfo.username;
});
// 文档
const onOpenDoc = () => {
window.open(APP_DOC, '_blank');
};
// 问题 & 帮助
const onOpenIssue = () => {
window.open(APP_ISSUES, '_blank');
};
// 退出登录
const onLogoutClick = () => {
// 1.清除 Token
userStore.logout();
// 2.重定向到登陆页
router.replace(LOGIN_URL);
};
</script>
<template>
<el-dropdown trigger="click" placement="bottom-end">
<div class="flex items-center cursor-pointer">
<div class="avatar relative">
<img src="@/assets/images/avatar.webp" alt="avatar" />
<span class="wk-badge !size-8px"></span>
</div>
</div>
<template #dropdown>
<el-dropdown-menu class="w-238px">
<div class="pl-15px pr-15px pt-10px pb-10px flex">
<div class="inline-flex items-center justify-center w-48px h-48px bg-secondary overflow-hidden rounded-full relative">
<img class="h-full w-full object-cover" src="@/assets/images/avatar.webp" alt="avatar" />
<span class="wk-badge"></span>
</div>
<div class="flex-1 ml-10px">
<div class="mt-4px flex items-center text-sm font-medium">
{{ username }}
</div>
<div class="mt-8px text-xs font-normal wk-role">角色管理员</div>
</div>
</div>
<el-dropdown-item divided @click="onOpenDoc">
<el-icon class="mt-2px" :size="16">
<i-wk-book-open theme="outline" />
</el-icon>
文档
</el-dropdown-item>
<el-dropdown-item @click="onOpenIssue">
<el-icon class="mt-2px" :size="16">
<i-wk-help theme="outline" :size="24" />
</el-icon>
问题 & 帮助
</el-dropdown-item>
<el-dropdown-item divided @click="onLogoutClick">
<el-icon class="mt-3px" :size="16">
<i-wk-power theme="outline" />
</el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<style lang="scss" scoped>
.username {
margin: 0 16px;
font-size: 14px;
height: 32px;
line-height: 32px;
}
.avatar {
width: 32px;
height: 32px;
overflow: hidden;
border-radius: 50%;
img {
width: 100%;
height: 100%;
}
}
.wk-role {
color: var(--el-text-color-regular);
}
.wk-badge {
position: absolute;
background-color: rgb(87, 209, 136);
vertical-align: middle;
border-radius: 50%;
width: 10px;
height: 10px;
display: inline-block;
bottom: 6px;
right: 6px;
}
</style>

View File

@@ -1,95 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { HOME_URL } from '@/config';
import { useRoute, useRouter } from 'vue-router';
import { ArrowRight } from '@element-plus/icons-vue';
import { useAuthStore } from '@/stores/modules/auth';
import { useGlobalStore } from '@/stores/modules/global';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const globalStore = useGlobalStore();
const breadcrumbList = computed(() => {
let breadcrumbData = authStore.breadcrumbListGet[route.matched[route.matched.length - 1].path] ?? [];
// 🙅‍♀️不需要首页面包屑可删除以下判断
if (breadcrumbData.length > 0 && breadcrumbData[0].path !== HOME_URL) {
breadcrumbData = [...breadcrumbData];
}
return breadcrumbData;
});
// Click Breadcrumb
const onBreadcrumbClick = (item: Menu.MenuOptions, index: number) => {
if (index !== breadcrumbList.value.length - 1) router.push(item.path);
};
</script>
<template>
<div :class="['breadcrumb-box', !globalStore.breadcrumbIcon && 'no-icon']">
<el-breadcrumb :separator-icon="ArrowRight">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
<div class="el-breadcrumb__inner is-link" @click="onBreadcrumbClick(item, index)">
<el-icon v-show="item.meta.icon && globalStore.breadcrumbIcon" class="breadcrumb-icon">
<component :is="item.meta.icon"></component>
</el-icon>
<span class="breadcrumb-title">{{ item.meta.title }}</span>
</div>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</div>
</template>
<style scoped lang="scss">
.breadcrumb-box {
display: flex;
align-items: center;
overflow: hidden;
.el-breadcrumb {
white-space: nowrap;
.el-breadcrumb__item {
position: relative;
display: inline-block;
float: none;
.el-breadcrumb__inner {
display: inline-flex;
&.is-link {
color: var(--el-header-text-color);
&:hover {
color: var(--el-color-primary);
}
}
.breadcrumb-icon {
margin-top: 2px;
margin-right: 6px;
font-size: 16px;
}
.breadcrumb-title {
margin-top: 3px;
}
}
&:last-child .el-breadcrumb__inner,
&:last-child .el-breadcrumb__inner:hover {
color: var(--el-header-text-color-regular);
}
:deep(.el-breadcrumb__separator) {
position: relative;
top: -1px;
}
}
}
}
.no-icon {
.el-breadcrumb {
.el-breadcrumb__item {
top: -2px;
:deep(.el-breadcrumb__separator) {
top: 2px;
}
}
}
}
</style>

View File

@@ -1,85 +0,0 @@
<script lang="ts" name="SubMenu" setup>
import { useRouter } from 'vue-router';
defineProps<{ menuList: Menu.MenuOptions[] }>();
const router = useRouter();
const handleClickMenu = (subItem: Menu.MenuOptions) => {
if (subItem.meta.isLink) return window.open(subItem.meta.isLink, '_blank');
router.push(subItem.path);
};
</script>
<template>
<template v-for="subItem in menuList" :key="subItem.path">
<el-sub-menu v-if="subItem.children?.length" :index="subItem.path">
<template #title>
<el-icon v-if="subItem.meta.icon">
<component :is="subItem.meta.icon"></component>
</el-icon>
<span class="sle">{{ subItem.meta.title }}</span>
</template>
<SubMenu :menu-list="subItem.children" />
</el-sub-menu>
<el-menu-item v-else :index="subItem.path" @click="handleClickMenu(subItem)">
<el-icon v-if="subItem.meta.icon">
<component :is="subItem.meta.icon"></component>
</el-icon>
<template #title>
<span class="sle">{{ subItem.meta.title }}</span>
</template>
</el-menu-item>
</template>
</template>
<style lang="scss">
.el-sub-menu .el-sub-menu__title:hover {
color: var(--el-menu-hover-text-color) !important;
background-color: transparent !important;
}
.el-menu--collapse {
.is-active {
.el-sub-menu__title {
color: #ffffff !important;
background-color: var(--el-color-primary) !important;
}
}
}
.el-menu-item {
&:hover {
color: var(--el-menu-hover-text-color);
}
&.is-active {
color: var(--el-menu-active-color) !important;
background-color: var(--el-menu-active-bg-color) !important;
&::before {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
content: '';
background-color: var(--el-color-primary);
}
}
}
.vertical,
.classic,
.transverse {
.el-menu-item {
&.is-active {
&::before {
left: 0;
}
}
}
}
.columns {
.el-menu-item {
&.is-active {
&::before {
right: 0;
}
}
}
}
</style>

View File

@@ -1,132 +0,0 @@
<script lang="ts" name="Main" setup>
// API 接口
import { systemApi } from '@/api/modules/system-api.ts';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/modules/auth';
import { useGlobalStore } from '@/stores/modules/global';
import { useUserStore } from '@/stores/modules/user';
import { HOME_URL, WK_CONFIG } from '@/config';
// 组件
import ToolBarLeft from '@/layouts/components/Header/ToolBarLeft.vue';
import ToolBarRight from '@/layouts/components/Header/ToolBarRight.vue';
import Main from '@/layouts/components/Main.vue';
import SubMenu from '@/layouts/components/menu/SubMenu.vue';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const isCollapse = computed(() => globalStore.isCollapse);
const menuList = computed(() => authStore.showMenuListGet);
const activeMenu = computed(() => (route.meta.activeMenu ? route.meta.activeMenu : route.path) as string);
const APP_TITLE = WK_CONFIG.APP_TITLE;
/**
* 获取系统设置
*/
const getSystemSettings = async () => {
const res = await systemApi.systemSettings();
userStore.setSystemSetting(res);
};
const onPageHome = () => {
router.push(HOME_URL);
};
onMounted(() => {
getSystemSettings();
});
</script>
<template>
<el-container class="layout">
<el-aside>
<div class="aside-box w-224px">
<div class="logo flex-center cursor-pointer" @click="onPageHome">
<img class="logo-img" src="/logo.png" alt="logo" />
<span class="logo-text">{{ APP_TITLE }}</span>
</div>
<el-scrollbar>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:router="false"
:unique-opened="true"
:collapse-transition="false"
>
<SubMenu :menu-list="menuList" />
</el-menu>
</el-scrollbar>
</div>
</el-aside>
<el-container>
<el-header>
<ToolBarLeft />
<ToolBarRight />
</el-header>
<Main />
</el-container>
</el-container>
</template>
<style lang="scss" scoped>
.el-container {
width: 100%;
height: 100%;
:deep(.el-aside) {
width: auto;
background-color: var(--el-menu-bg-color);
border-right: 1px solid var(--el-aside-border-color);
.aside-box {
display: flex;
flex-direction: column;
height: 100%;
transition: width 0.3s ease;
.el-scrollbar {
height: calc(100% - 55px);
.el-menu {
width: 100%;
overflow-x: hidden;
border-right: none;
}
}
.logo {
box-sizing: border-box;
height: 55px;
.logo-img {
width: 28px;
object-fit: contain;
margin-right: 6px;
}
.logo-text {
font-size: 20px;
font-weight: bold;
color: var(--el-aside-logo-text-color);
white-space: nowrap;
}
}
}
}
.el-header {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
height: 50px;
padding: 0 12px;
background-color: var(--el-header-bg-color);
border-bottom: 1px solid var(--el-header-border-color);
:deep(.tool-bar-ri) {
.toolBar-icon,
.username {
color: var(--el-header-text-color);
}
}
}
}
</style>

View File

@@ -1,35 +1,15 @@
import App from '@/App.vue';
// router
import router from './router';
// pinia store
import pinia from '@/stores';
// vue i18n
import I18n from '@/i18n';
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import 'uno.css';
import '@/styles/index.scss';
import router from './router'
// element icons
import * as Icons from '@element-plus/icons-vue';
// icon-park
import { install } from '@icon-park/vue-next/es/all';
import 'vue-global-api';
// import APIClient from './services/APIClient'
// APIClient.shared.config.apiURL = "http://localhost:5300" // 本地调试用,正式环境请注释掉
import { setupUi } from '@/ui';
const app = createApp(App)
import { createApp } from 'vue';
app.use(router)
const app = createApp(App);
install(app, 'i-wk');
// register the element Icons component
Object.keys(Icons).forEach(key => {
app.component(key, Icons[key as keyof typeof Icons]);
});
app.use(router);
app.use(setupUi);
app.use(I18n);
app.use(pinia);
app.mount('#app');
app.mount('#app')

View File

@@ -1,14 +0,0 @@
const menuModuleList: any[] = [];
const modules = import.meta.glob('./modules/**/*.ts', { import: 'default', eager: true });
Object.keys(modules).forEach(key => {
const mod = modules[key] || {};
const modList = Array.isArray(mod) ? [...mod] : [mod];
menuModuleList.push(...modList);
});
// 按照index 升序
menuModuleList.sort((a, b) => {
return a.meta.index - b.meta.index;
});
export default [...menuModuleList];

View File

@@ -1,81 +0,0 @@
const cluster: Menu.MenuOptions = {
name: 'cluster',
path: '/cluster',
meta: {
icon: 'i-wk-branch-two',
isAffix: true,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '分布式'
},
children: [
{
name: 'cluster_nodes',
path: '/cluster/nodes',
meta: {
icon: 'i-wk-connection-point',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '节点'
}
},
{
name: 'cluster_slots',
path: '/cluster/slots',
meta: {
icon: 'i-wk-insert-card',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '分区(槽)'
}
},
{
name: 'cluster_channels',
path: '/cluster/channels',
meta: {
icon: 'i-wk-broadcast',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '频道'
}
},
{
name: 'cluster_config',
path: '/cluster/config',
meta: {
icon: 'i-wk-setting-config',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '配置'
}
},
{
name: 'cluster_log',
path: '/cluster/log',
meta: {
icon: 'i-wk-log',
isAffix: false,
isFull: false,
isHide: true,
isKeepAlive: true,
isLink: '',
title: '日志'
}
}
]
};
export default cluster;

View File

@@ -1,94 +0,0 @@
const data: Menu.MenuOptions = {
name: 'data',
path: '/data',
meta: {
icon: 'i-wk-data-one',
isAffix: true,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '数据'
},
children: [
{
name: 'data_connection',
path: '/data/connection',
meta: {
icon: 'i-wk-api',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '链接'
}
},
{
name: 'data_user',
path: '/data/user',
meta: {
icon: 'i-wk-user',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '用户'
}
},
{
name: 'data_device',
path: '/data/device',
meta: {
icon: 'i-wk-devices',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '设备'
}
},
{
name: 'data_message',
path: '/data/message',
meta: {
icon: 'i-wk-message',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '消息'
}
},
{
name: 'data_channel',
path: '/data/channel',
meta: {
icon: 'i-wk-broadcast',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '频道'
}
},
{
name: 'data_conversation',
path: '/data/conversation',
meta: {
icon: 'i-wk-communication',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '会话'
}
}
]
};
export default data;

View File

@@ -1,68 +0,0 @@
const monitor: Menu.MenuOptions = {
name: 'monitor',
path: '/monitor',
meta: {
icon: 'i-wk-monitor',
isAffix: true,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '监控'
},
children: [
{
name: 'monitor_app',
path: '/monitor/app',
meta: {
icon: 'i-wk-application-one',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '应用'
}
},
{
name: 'monitor_system',
path: '/monitor/system',
meta: {
icon: 'i-wk-system',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '系统'
}
},
{
name: 'monitor_cluster',
path: '/monitor/cluster',
meta: {
icon: 'i-wk-connection-box',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '分布式'
}
},
{
name: 'monitor_trace',
path: '/monitor/trace',
meta: {
icon: 'i-wk-trace',
isAffix: false,
isFull: false,
isHide: false,
isKeepAlive: true,
isLink: '',
title: '消息追踪'
}
}
]
};
export default monitor;

View File

@@ -0,0 +1,421 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
import { useRouter } from "vue-router";
import App, { ActionWrite } from '../../services/App';
import { alertNoPermission } from '../../services/Utils';
const router = useRouter()
const nodeTotal = ref<any>({}); // 节点列表
const selectedNodeId = ref<number>(1) // 选中的节点ID
const channelTotal = ref<any>({}); // 频道列表
const loading = ref<boolean>(false);
const selectedChannel = ref<any>({}); // 选中的频道
const selectedMigrateFrom = ref<number>() // 选中的源节点ID
const selectedMigrateTo = ref<number>() // 选中的目标节点ID
const config = ref<any>({}); // 频道配置
const channelId = ref<string>() // 接受频道id
const channelType = ref<number>() // 频道类型
const pageSize = ref<number>(20) // 每页数量
const currentPage = ref(1); // 当前页
const replicas = ref<any>({}); // 副本
const offsetCreatedAt = ref(0); // 偏移量
const pre = ref<boolean>() // 是否向上分页
const hasNext = ref<boolean>(true) // 是否有下一页
const hasPrev = ref<boolean>(false) // 是否有上一页
onMounted(() => {
API.shared.simpleNodes().then((res) => {
nodeTotal.value = res
if (nodeTotal.value.data.length > 0) {
selectedNodeId.value = nodeTotal.value.data[0].id
}
loadNodeChannels()
}).catch((err) => {
alert(err)
})
})
const onNodeChange = (e: any) => {
resetFilter()
selectedNodeId.value = parseInt(e.target.value)
loadNodeChannels()
}
const loadNodeChannels = () => {
loading.value = true;
API.shared.nodeChannelConfigs({
channelId: channelId.value,
channelType: channelType.value,
nodeId: selectedNodeId.value,
limit: pageSize.value,
offsetCreatedAt: offsetCreatedAt.value,
pre: pre.value
}).then((res) => {
channelTotal.value = res
hasNext.value = channelTotal.value?.more === 1
hasPrev.value = currentPage.value > 1
if (pre.value) { // 如果是向上翻页,则有下页数据
hasNext.value = true
}
}).catch((err) => {
alert(err)
}).finally(() => {
loading.value = false;
})
}
const onShowMigrateModal = (ch: any) => {
if (!App.shard().loginInfo.hasPermission('channelMigrate', ActionWrite)) {
alertNoPermission()
return
}
selectedChannel.value = ch;
const migrateModal = document.getElementById('migrateModal') as HTMLDialogElement;
migrateModal.showModal();
}
const onMigrate = () => {
const migrateModal = document.getElementById('migrateModal') as HTMLDialogElement;
migrateModal.close();
API.shared.migrateChannel({
channelId: selectedChannel.value.channel_id,
channelType: selectedChannel.value.channel_type,
migrateFrom: selectedMigrateFrom.value || 0,
migrateTo: selectedMigrateTo.value || 0,
}).then((_) => {
loadNodeChannels()
}).catch((err) => {
alert(err.msg)
})
}
const onChannelClusterConfig = (ch: any) => {
resetFilter()
selectedChannel.value = ch;
API.shared.channelClusterConfig({
channelId: selectedChannel.value.channel_id,
channelType: selectedChannel.value.channel_type,
nodeId: selectedNodeId.value
}).then((res) => {
config.value = res
const modal = document.getElementById('channelClusterModal') as HTMLDialogElement;
modal.showModal();
}).catch((err) => {
alert(err)
})
}
const onChannelStartOrStop = (ch: any) => {
if (!App.shard().loginInfo.hasPermission('channelStart', ActionWrite)) {
alertNoPermission()
return
}
const req = {
channelId: ch.channel_id,
channelType: ch.channel_type,
nodeId: selectedNodeId.value
}
if (ch.active == 1) {
API.shared.channelStop(req).then((_) => {
loadNodeChannels()
}).catch((err) => {
alert(err.msg)
})
} else {
API.shared.channelStart(req).then((_) => {
loadNodeChannels()
}).catch((err) => {
alert(err.msg)
})
}
}
const resetFilter = () => {
currentPage.value = 1
offsetCreatedAt.value = 0
pre.value = false
hasNext.value = true
hasPrev.value = false
}
// 上一页
const prevPage = () => {
if (currentPage.value <= 1) {
hasPrev.value = false
return
}
hasPrev.value = true
currentPage.value -= 1
pre.value = true
if (channelTotal.value?.data?.length > 0) {
offsetCreatedAt.value = channelTotal.value.data[0].created_at
}
loadNodeChannels()
}
// 下一页
const nextPage = () => {
if (channelTotal.value?.more === 0 && !pre.value) {
return
}
currentPage.value += 1
pre.value = false
if (channelTotal.value?.data?.length > 0) {
offsetCreatedAt.value = channelTotal.value.data[channelTotal.value.data.length - 1].created_at
}
loadNodeChannels()
}
const onChannelIdSearch = (e: any) => {
channelId.value = e.target.value
if (!channelId.value || channelId.value.trim() == '') {
channelType.value = 0
}
currentPage.value = 1
loadNodeChannels()
}
const onChannelTypeSearch = (e: any) => {
channelType.value = e.target.value
if (!channelId.value || channelId.value.trim() == '') {
return
}
currentPage.value = 1
loadNodeChannels()
}
// const onRunningSearch = (e: any) => {
// running.value = e.target.checked
// currentPage.value = 1
// loadNodeChannels()
// }
// 查看副本
const onReplicas = (ch: any) => {
const modal = document.getElementById('replicas') as HTMLDialogElement;
modal.showModal();
API.shared.channelReplicas({
channelId: ch.channel_id,
channelType: ch.channel_type,
}).then((res) => {
replicas.value = res
const modal = document.getElementById('replicas') as HTMLDialogElement;
modal.showModal();
}).catch((err) => {
alert(err)
})
}
// 查看消息
const onMessage = (ch: any) => {
router.push({
path: '/data/message',
query: {
channelId: ch.channel_id,
channelType: ch.channel_type,
}
})
}
</script>
<template>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex">
<div class="text-sm ml-3">
<label>节点</label>
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onNodeChange">
<option v-for="node in nodeTotal.data" :selected="node.id == selectedNodeId" :value="node.id">{{
node.id }}
</option>
</select>
</div>
<!-- 频道 -->
<div class="text-sm ml-10">
<label>接受频道</label>
<select class="select select-bordered max-w-xs select-sm w-20 ml-2"
v-on:change="onChannelTypeSearch" v-model="channelType">
<option value="1">个人</option>
<option value="2">群聊</option>
<option value="3">客服</option>
<option value="4">社区</option>
<option value="5">话题</option>
<option value="6">资讯</option>
<option value="7">数据</option>
</select>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onChannelIdSearch" />
</div>
</div>
<table class="table mt-10 table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>频道ID</th>
<th>频道类型</th>
<th>所属槽</th>
<th>槽领导</th>
<th>频道领导</th>
<th>任期</th>
<th>副本节点</th>
<th>消息高度</th>
<th>最后消息时间</th>
<th>是否运行</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="channel in channelTotal.data">
<td class="text-blue-800">{{ channel.channel_id }}</td>
<td>{{ channel.channel_type_format }}</td>
<td>{{ channel.slot_id }}</td>
<td>{{ channel.slot_leader_id }}</td>
<td>{{ channel.leader_id }}</td>
<td>{{ channel.term }}</td>
<td>{{ channel.replicas }}&nbsp;&nbsp;<label class="text-red-500"
v-if="channel.migrate_from != 0">[{{ channel.migrate_from }} 迁移至 {{ channel.migrate_to
}}
]</label></td>
<td>{{ channel.last_message_seq }}</td>
<td>{{ channel.last_append_time }}</td>
<td :class="channel.active == 1 ? 'text-green-500' : 'text-red-500'">{{ channel.active_format }}
</td>
<td :class="channel.status == 0 ? 'text-green-500' : 'text-red-500'">{{ channel.status_format }}
</td>
<td class="flex flex-wrap gap-2">
<button
:class="channel.active == 1 ? 'btn btn-primary btn-sm btn-secondary' : 'btn btn-primary btn-sm'"
v-on:click="() => onChannelStartOrStop(channel)">{{ channel.active
== 1 ? "停止" : "开始" }}</button>
<button class="btn btn-primary btn-sm"
v-on:click="() => onShowMigrateModal(channel)">迁移</button>
<button class="btn btn-primary btn-sm"
v-on:click="() => onChannelClusterConfig(channel)">配置</button>
<button class="btn btn-primary btn-sm" v-on:click="() => onReplicas(channel)">副本</button>
<button class="btn btn-primary btn-sm" v-on:click="() => onMessage(channel)">消息</button>
</td>
</tr>
</tbody>
</table>
<!-- <div class="flex flex-col gap-4 w-full mt-2" v-if="loading">
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
</div> -->
</div>
<div class="flex items-center justify-end mt-10">
<div class="flex">
<div className="join">
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasPrev }"
v-on:click="prevPage">«</button>
<button className="join-item btn">{{ currentPage }}</button>
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasNext }"
v-on:click="nextPage">»</button>
</div>
</div>
</div>
<dialog id="migrateModal" class="modal">
<div class="modal-box flex flex-wrap gap-2 justify-center">
<select class="select select-bordered" v-model="selectedMigrateFrom">
<option v-for="nodeId in selectedChannel.replicas" :value="nodeId">{{ nodeId }}</option>
</select>
<div class="flex items-center">&nbsp;&nbsp;迁移至&nbsp;&nbsp;</div>
<select class="select select-bordered" v-model="selectedMigrateTo">
<option v-for="destNode in nodeTotal.data" :value="destNode.id">{{ destNode.id }}</option>
</select>
&nbsp;&nbsp;
<button className="btn-primary btn" v-on:click="onMigrate">确认</button>
<!-- <a class="link" v-for="uid in currentUids">{{ uid }}</a> -->
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="channelClusterModal" class="modal">
<div class="modal-box flex flex-wrap gap-2 justify-center">
<vue-json-pretty :data="config" class="overflow-auto" />
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="replicas" class="modal">
<div class="modal-box flex flex-wrap gap-2 justify-center">
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr>
<th>节点ID</th>
<th>节点角色</th>
<th>消息高度</th>
<th>最后消息时间</th>
<th>是否运行</th>
</tr>
</thead>
<tbody>
<tr v-for="replica in replicas">
<td>{{ replica.replica_id }}</td>
<td>{{ replica.role_format }}</td>
<td>{{ replica.last_msg_seq }}</td>
<td>{{ replica.last_msg_time_format }}</td>
<td :class="replica.running == 1 ? 'text-green-500' : 'text-red-500'">
{{ replica.running == 1 ? '运行中' : '未运行' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -1,31 +1,27 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
<script lang="ts" setup>
import { defineComponent, onMounted, ref } from 'vue';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
import API from '../../services/API';
const config = ref<any>({});
const getConfig = async () => {
config.value = await clusterApi.clusterConfig();
};
defineComponent({
components: {
VueJsonPretty,
},
});
onMounted(() => {
getConfig();
console.log('mounted');
API.shared.clusterConfig().then((res) => {
config.value = res
});
});
</script>
<template>
<wk-page class="flex-col overflow-hidden">
<div class="flex-1 card">
<VueJsonPretty :data="config" />
</div>
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 配置
</route>
<vue-json-pretty :data="config" class="overflow-auto"/>
</template>

View File

@@ -1,157 +1,94 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import { useRoute } from 'vue-router';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { useRouter } from "vue-router";
const route = useRoute();
const router = useRouter()
const query = router.currentRoute.value.query; //查询参数
/**
* 查询条件
*/
interface IFormData {
node_id?: string;
slot?: string;
log_type?: string;
pre: number;
next: number;
}
const logTotal = ref<any>({});
const formData = reactive<IFormData>({
node_id: '',
slot: '',
log_type: '',
pre: 0,
next: 0
});
/**
* 表格
*/
const tableRef = ref<VxeGridInstance<any>>();
const applied = ref(0);
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有上一页
const hasNext = ref<boolean>(true); // 是否有下一页
const loadList = async (query: any) => {
const res = await clusterApi.clusterLogs({ ...query });
applied.value = res.applied || 0;
if (res.logs) {
hasNext.value = res.more !== 1;
hasPrev.value = currentPage.value <= 1;
return res.logs;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formData);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'id', title: '日志ID', width: 180 },
{ field: 'index', title: '日志下标', width: 100 },
{ field: 'term', title: '日志任期', width: 100 },
{ field: 'cmd', title: '命令类型', minWidth: 160 },
{ field: 'data', title: '命令内容', minWidth: 420 },
{ field: 'time_format', title: '日志时间', width: 160 },
{
field: 'status',
title: '状态',
width: 90,
formatter({ row }) {
return row.index <= applied.value ? '已应用' : '未应用';
}
}
]
});
/**
* 分页
* @param type
*/
const onPage = (type: 0 | 1) => {
// 下一页
const tableData = tableRef.value?.getData();
if (type === 0) {
formData.pre = 0;
currentPage.value = currentPage.value + 1;
if (tableData && tableData.length > 0) {
formData.next = tableData[tableData.length - 1].index;
}
}
// 上一页
if (type === 1 && currentPage.value > 1) {
formData.next = 0;
currentPage.value = currentPage.value - 1;
if (tableData && tableData.length > 0) {
formData.pre = tableData[0].index;
}
}
tableRef.value?.commitProxy('query');
};
const nextIndex = ref<number>(0);
const preIndex = ref<number>(0);
onMounted(() => {
if (route.query) {
formData.node_id = (route.query?.node_id as string) || '';
formData.log_type = (route.query?.log_type as string) || '';
formData.slot = (route.query?.slot as string) || '';
}
console.log('mounted');
loadData();
});
const loadData = () => {
const nodeId = Number(query.node_id);
const slot = Number(query.slot);
const logType = query.log_type as string || "";
API.shared.clusterLogs({
nodeId: nodeId,
slot: slot,
next: nextIndex.value,
pre: preIndex.value,
logType: logType
}).then((res) => {
logTotal.value = res
});
}
const prevPage = () => {
preIndex.value = logTotal.value.pre || 0;
nextIndex.value = 0;
loadData();
}
const nextPage = () => {
preIndex.value = 0;
nextIndex.value = logTotal.value.next || 0;
loadData();
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<route lang="yaml">
meta:
title: 日志
</route>
<template>
<div>
<div class="overflow-x-auto h-5/6">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>日志Id</th>
<th>日志下标</th>
<th>日志任期</th>
<th>命令类型</th>
<th>命令内容</th>
<th>日志时间</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="log in logTotal.logs">
<td>{{ log.id }}</td>
<td>{{ log.index }}</td>
<td>{{ log.term }}</td>
<td>{{ log.cmd }}</td>
<td>{{ log.data }}</td>
<td>{{ log.time_format }}</td>
<td v-if="log.index <= logTotal.applied">已应用</td>
<td v-if="log.index > logTotal.applied" class="text-red-500">未应用</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center mt-10 justify-end">
<div class="flex">
<div className="join">
<button :class="{'join-item btn':true,'btn-disabled':logTotal.logs && logTotal.logs.length>0&&logTotal.logs[0].index==logTotal.last}" v-on:click="prevPage">«</button>
<!-- <button className="join-item btn">{{ currentPage }}</button> -->
<button :class="{'join-item btn':true,'btn-disabled':logTotal.logs && logTotal.logs.length>0&&logTotal.logs[logTotal.logs.length-1].index==1}" v-on:click="nextPage">»</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { useRouter } from "vue-router";
const router = useRouter()
const nodeTotal = ref<any>({});
const loading = ref<boolean>(false);
onMounted(() => {
console.log('mounted');
loading.value = true;
API.shared.nodes().then((res) => {
nodeTotal.value = res
}).catch((err) => {
if(err.msg) {
alert(err)
}
}).finally(() => {
loading.value = false;
})
});
const getRole = (node: any) => {
if (node.is_leader == 1) {
return '领导'
}
if (node.role == 1) {
return '代理'
}
return '副本'
}
const onLog = (node: any) => {
console.log(node)
router.push({
path: '/cluster/log',
query: {
node_id: node.id
}
})
}
</script>
<template>
<div>
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>节点ID</th>
<th>角色</th>
<th>任期</th>
<th>槽领导/槽数量</th>
<th>投票权</th>
<th>在线</th>
<th>离线次数</th>
<th>运行时间</th>
<th>地址</th>
<th>程序版本</th>
<th>配置版本</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="node in nodeTotal.data">
<td class="text-blue-800">
<RouterLink to="/node/detail">{{ node.id }}</RouterLink>
</td>
<td :class="node.is_leader == 1 ? 'text-blue-400' : ''">{{ getRole(node) }}</td>
<td>{{node.term}}</td>
<td>{{ node.slot_leader_count }}/{{ node.slot_count }}</td>
<td>{{ node.allow_vote == 1 ? '有' : '无' }}</td>
<td :class="node.online == 1 ? 'text-green-500' : ''">{{ node.online == 1 ? '在线' : '离线' }}</td>
<td>{{node.offline_count>0?`${node.offline_count}(${node.last_offline})`:`0`}}</td>
<td>{{ node.uptime }}</td>
<td>{{ node.cluster_addr }}</td>
<td>{{ node.app_version }}</td>
<td>{{ node.config_version }}</td>
<td>{{ node.status_format }}</td>
<td>
<button class="btn btn-sm btn-primary" v-on:click="()=>onLog(node)">日志</button>
</td>
</tr>
</tbody>
</table>
<div class="flex flex-col gap-4 w-full mt-2" v-if="loading">
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { useRouter } from "vue-router";
import App, { ActionWrite } from '../../services/App';
import { alertNoPermission } from '../../services/Utils';
const router = useRouter()
const nodeTotal = ref<any>({}); // 节点列表
const slotTotal = ref<any>({});
const loading = ref<boolean>(false);
const orderBy = ref<string>('') // 排序字段
const desc = ref<boolean>(false) // 是否降序
const selectedMigrateFrom = ref<number>() // 选中的源节点ID
const selectedMigrateTo = ref<number>() // 选中的目标节点ID
const selectedSlot = ref<any>({}); // 选中的槽
onMounted(() => {
loadData()
API.shared.simpleNodes().then((res) => {
nodeTotal.value = res
}).catch((err) => {
alert(err)
})
});
const loadData = () => {
loading.value = true;
API.shared.slots().then((res) => {
slotTotal.value = res
}).catch((err) => {
alert(err)
}).finally(() => {
loading.value = false;
})
}
const onSort = (by :string) => {
orderBy.value = by
sort()
}
const sort = () => {
if(orderBy.value === 'channel_count') {
if(desc.value) {
slotTotal.value.data.sort((a: any, b: any) => {
return a.channel_count - b.channel_count
})
desc.value = false
} else {
slotTotal.value.data.sort((a: any, b: any) => {
return b.channel_count - a.channel_count
})
desc.value = true
}
} else if(orderBy.value === 'log_index') {
if(desc.value) {
slotTotal.value.data.sort((a: any, b: any) => {
return a.log_index - b.log_index
})
desc.value = false
} else {
slotTotal.value.data.sort((a: any, b: any) => {
return b.log_index - a.log_index
})
desc.value = true
}
}
}
const onLog = (slot: any) => {
router.push({
path: '/cluster/log',
query: {
slot: slot.id,
node_id: slot.leader_id,
log_type: 2,
}
})
}
const onShowMigrateModal = (slot: any) => {
if(!App.shard().loginInfo.hasPermission('slotMigrate',ActionWrite)) {
alertNoPermission()
return
}
selectedSlot.value = slot
const migrateModal = document.getElementById('migrateModal') as HTMLDialogElement
migrateModal.showModal()
}
const onMigrate = () => {
const migrateModal = document.getElementById('migrateModal') as HTMLDialogElement;
migrateModal.close();
API.shared.migrateSlot({
slot: selectedSlot.value.id,
migrateFrom: selectedMigrateFrom.value||0,
migrateTo: selectedMigrateTo.value||0
}).then(() => {
loadData()
}).catch((err) => {
alert(err.msg)
})
}
</script>
<template>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex">
<div class="text-sm ml-3">
<button class="btn" :onclick="() => { onSort('channel_count') }">按频道数量排序</button>
</div>
<div class="text-sm ml-3">
<button class="btn" :onclick="() => { onSort('log_index') }">日志高度</button>
</div>
</div>
<table class="table table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>分区</th>
<th>领导节点</th>
<th>任期</th>
<th>副本节点</th>
<th>频道数量</th>
<th>日志高度</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="slot in slotTotal.data">
<td>{{ slot.id }}</td>
<td>{{ slot.leader_id }}</td>
<td>{{ slot.term }}</td>
<td>{{ slot.replicas }}</td>
<td>{{ slot.channel_count }}</td>
<td>{{slot.log_index}}</td>
<td :class="slot.status===0?'text-green-500':'text-red-500'">{{slot.status_format}}</td>
<td class="flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" v-on:click="()=>onShowMigrateModal(slot)">迁移</button>
<button class="btn btn-sm btn-primary" v-on:click="()=>onLog(slot)">日志</button>
</td>
</tr>
</tbody>
</table>
<div class="flex flex-col gap-4 w-full mt-2" v-if="loading">
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
</div>
</div>
<dialog id="migrateModal" class="modal">
<div class="modal-box flex flex-wrap gap-2 justify-center">
<select class="select select-bordered" v-model="selectedMigrateFrom">
<option v-for="nodeId in selectedSlot.replicas" :value="nodeId">{{ nodeId }}</option>
</select>
<div class="flex items-center">&nbsp;&nbsp;迁移至&nbsp;&nbsp;</div>
<select class="select select-bordered" v-model="selectedMigrateTo">
<option v-for="destNode in nodeTotal.data" :value="destNode.id">{{ destNode.id }}</option>
</select>
&nbsp;&nbsp;
<button className="btn-primary btn" v-on:click="onMigrate">确认</button>
<!-- <a class="link" v-for="uid in currentUids">{{ uid }}</a> -->
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -1,69 +0,0 @@
<script name="ClusterConfig" lang="ts" setup>
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import VueJsonPretty from 'vue-json-pretty';
import 'vue-json-pretty/lib/styles.css';
interface IProps {
channelId: string;
channelType: number;
nodeId: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0,
nodeId: 0
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
getConfig();
} else {
config.value = {};
}
}
}
);
const config = ref<any>({});
const getConfig = async () => {
const res = await clusterApi.channelClusterConfig({ ...props });
config.value = res || {};
};
</script>
<template>
<vxe-modal
v-model="showModel"
title="配置"
:width="560"
:height="460"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<div class="flex-1 card pt-0px overflow-hidden">
<VueJsonPretty :data="config" />
</div>
</wk-page>
</vxe-modal>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,185 +0,0 @@
<script name="Migrate" lang="ts" setup>
// API 接口
import { clusterApi } from '@/api/modules/cluster-api.ts';
import { ElMessage } from 'element-plus';
import type { VxeFormProps } from 'vxe-pc-ui';
interface IProps {
channelId: string;
channelType: number;
replicas: number[];
}
interface IReplicasItem {
value: number;
label: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0,
replicas: []
});
const showModel = defineModel<boolean>();
const emits = defineEmits(['submit']);
const loadingModel = ref(false);
const replicasList = ref<IReplicasItem[]>([]);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
getNodes();
const getReplicas: IReplicasItem[] = [];
props.replicas.map(item => {
getReplicas.push({
value: item,
label: item
});
});
replicasList.value = getReplicas;
formOptions.data = {
...formOptions.data,
channelId: props.channelId,
channelType: props.channelType
} as FormDataVO;
}
} else {
formOptions.data = {
channelId: '',
channelType: 0,
migrateFrom: null,
migrateTo: null
};
}
}
);
const nodeList = ref();
const getNodes = async () => {
const nodes: any[] = [];
const res = await clusterApi.simpleNodes();
if (res.data.length > 0) {
res.data.map((item: any) => {
nodes.push({
value: item.id,
label: item.id
});
});
}
nodeList.value = nodes;
};
/**
* 表单
*/
interface FormDataVO {
channelId: string;
channelType: number;
migrateFrom: number | null;
migrateTo: number | null;
}
const formRef = ref();
const formOptions = reactive<VxeFormProps<FormDataVO>>({
loading: false,
data: {
channelId: '',
channelType: 0,
migrateFrom: null,
migrateTo: null
},
rules: {
migrateFrom: [{ required: true, message: '请选择副本节点' }],
migrateTo: [{ required: true, message: '请选择迁移至节点' }]
},
titleBold: true,
titleWidth: 100,
titleColon: true,
titleOverflow: true,
items: [
{
field: 'migrateFrom',
title: '副本节点',
span: 24,
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择副本节点'
},
options: replicasList
}
},
{
field: 'migrateTo',
title: '迁移至节点',
span: 24,
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择迁移至节点'
},
options: nodeList
}
}
]
});
// 提交功能
const onFuncConfirm = async () => {
const validate = await formRef.value.validate();
if (validate) return;
clusterApi.migrateChannel(formOptions.data as any).then(() => {
ElMessage({
message: '操作成功!',
type: 'success',
plain: true
});
emits('submit')
});
};
onMounted(() => {
getNodes();
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="迁移"
:width="460"
:height="360"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
show-footer
show-confirm-button
show-cancel-button
@confirm="onFuncConfirm"
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表单 -->
<div class="flex-1 card overflow-hidden">
<vxe-form ref="formRef" v-bind="formOptions"></vxe-form>
</div>
<!-- E 表单 -->
</wk-page>
</vxe-modal>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,105 +0,0 @@
<script name="Replicas" lang="ts" setup>
// API 接口
import { clusterApi } from '@/api/modules/cluster-api.ts';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
interface IProps {
channelId: string;
channelType: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
tableRef.value?.commitProxy('query');
}
}
}
);
/**
* 表格
*/
interface ITableItem {
uid: string;
}
const tableRef = ref<VxeGridInstance<ITableItem>>();
const apiLoadList = async () => {
const res = await clusterApi.channelReplicas({ channelId: props.channelId, channelType: props.channelType });
return res || [];
};
const gridOptions = reactive<VxeGridProps<ITableItem>>({
border: true,
showOverflow: true,
height: 'auto',
stripe: true,
rowConfig: { isCurrent: true, isHover: true },
scrollY: { enabled: true, gt: 0 },
proxyConfig: {
ajax: {
query: () => {
return apiLoadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 64 },
{ field: 'replica_id', title: '节点ID', minWidth: 120 },
{ field: 'role_format', title: '节点角色', minWidth: 120 },
{ field: 'last_msg_seq', title: '消息高度', minWidth: 120 },
{ field: 'last_msg_time_format', title: '最后消息时间', minWidth: 120 },
{
field: 'running',
title: '是否运行',
width: 120,
formatter({ cellValue }) {
return cellValue === 1 ? '运行中' : '未运行';
}
}
]
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="副本"
:width="860"
:height="560"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表格 -->
<div class="flex-1 card pt-0px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</vxe-modal>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,408 +0,0 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
// 常量
import { ActionWrite, CHANNEL_TYPE } from '@/constants';
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import { ElMessage, ElMessageBox } from 'element-plus';
import Migrate from './components/Migrate.vue';
import Replicas from './components/Replicas.vue';
import ClusterConfig from './components/ClusterConfig.vue';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/modules/user.ts';
const router = useRouter();
const userStore = useUserStore();
/**
* 查询条件
* */
const nodeList = ref<any[]>([]);
const formOptions = reactive<VxeFormProps<any>>({
data: {
nodeId: null,
channel_type: null,
channel_id: null,
offset_created_at: null,
limit: 20,
pre: 0
},
items: [
{
field: 'nodeId',
title: '节点',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: nodeList
}
},
{
field: 'channel_type',
title: '频道类型',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择频道类型',
style: { width: '180px' }
},
options: CHANNEL_TYPE
}
},
{
field: 'channel_id',
title: '频道ID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入频道ID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
const getNodes = async () => {
const nodes: any[] = [];
const res = await clusterApi.simpleNodes();
if (res.data.length > 0) {
res.data.map((item: any) => {
nodes.push({
value: item.id,
label: item.id
});
});
}
nodeList.value = nodes;
if (nodes.length > 0) {
formOptions.data = {
...formOptions.data,
nodeId: nodes[0].value
};
tableRef.value?.commitProxy('query');
}
};
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
formOptions.data = {
...formOptions.data
};
if (nodeList.value.length > 0) {
formOptions.data = {
...formOptions.data,
nodeId: nodeList.value[0].value
};
}
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有上一页
const hasNext = ref<boolean>(true); // 是否有下一页
const loadList = async (query: any) => {
const res = await clusterApi.nodeChannelConfigs({ ...query });
if (res.data) {
hasNext.value = res.more !== 1;
hasPrev.value = currentPage.value <= 1;
return res.data;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
autoLoad: false,
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ field: 'channel_id', title: '频道ID', minWidth: 220, fixed: 'left' },
{ field: 'channel_type_format', title: '频道类型', minWidth: 120 },
{ field: 'slot_id', title: '所属槽', minWidth: 120 },
{ field: 'slot_leader_id', title: '槽领导', minWidth: 120 },
{ field: 'leader_id', title: '频道领导', minWidth: 120 },
{ field: 'term', title: '任期', minWidth: 120 },
{ field: 'replicas', title: '副本节点', minWidth: 160 },
{ field: 'last_message_seq', title: '消息高度', minWidth: 120 },
{ field: 'last_append_time', title: '最后消息时间', minWidth: 120 },
{ field: 'active_format', title: '是否运行', minWidth: 100 },
{ field: 'status_format', title: '状态', minWidth: 100 },
{ field: 'action', title: '操作', width: 224, fixed: 'right', slots: { default: 'action' } }
]
});
/**
* 分页切换
*/
const onPage = (type: 0 | 1) => {
// 下一页
if (type === 0) {
currentPage.value = currentPage.value + 1;
}
// 上一页
if (type === 1 && currentPage.value > 1) {
currentPage.value = currentPage.value - 1;
}
formOptions.data = {
...formOptions.data,
pre: type
};
tableRef.value?.commitProxy('query');
};
/**
* 操作开始或者停止
*/
const onStartOrStop = (row: any) => {
if (!userStore.hasPermission('channelStart', ActionWrite)) {
return ElMessage({
message: '没有操作权限!',
type: 'warning',
plain: true
});
}
ElMessageBox({
title: '系统提示',
message: `是否确认${row.active === 1 ? '停止' : '开始'}频道ID为【${row.channel_id}】的数据项?`,
type: 'warning',
showCancelButton: true,
confirmButtonText: '确定',
cancelButtonText: '取消',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
// 停止
if (row.active === 1) {
clusterApi
.channelStop({
channelId: row.channel_id,
channelType: row.channel_type,
nodeId: row.leader_id
})
.then(() => {
done();
})
.catch(() => {
instance.confirmButtonLoading = false;
});
}
// 开始
if (row.active === 0) {
clusterApi
.channelStart({
channelId: row.channel_id,
channelType: row.channel_type,
nodeId: row.leader_id
})
.then(() => {
done();
})
.catch(() => {
instance.confirmButtonLoading = false;
});
}
} else {
done();
}
}
})
.then(() => {
ElMessage({
message: '操作成功!',
type: 'success',
plain: true
});
tableRef.value?.commitProxy('query');
})
.catch(() => {
console.log('点击取消');
});
};
/**
* 迁移
*/
const modelMigrate = ref(false);
const channelIdMigrate = ref('');
const channelTypeMigrate = ref(0);
const replicasMigrate = ref<number[]>([]);
const onMigrate = (row: any) => {
if (!userStore.hasPermission('channelMigrate', ActionWrite)) {
return ElMessage({
message: '没有操作权限!',
type: 'warning',
plain: true
});
}
channelIdMigrate.value = row.channel_id;
channelTypeMigrate.value = row.channel_type;
replicasMigrate.value = row.replicas;
modelMigrate.value = true;
};
const onSubmit = () => {
tableRef.value?.commitProxy('query');
};
/**
* 配置
*/
const modelClusterConfig = ref(false);
const channelIdClusterConfig = ref('');
const channelTypeClusterConfig = ref(0);
const nodeIdClusterConfig = ref(0);
const onClusterConfig = (row: any) => {
channelIdClusterConfig.value = row.channel_id;
channelTypeClusterConfig.value = row.channel_type;
nodeIdClusterConfig.value = row.leader_id;
modelClusterConfig.value = true;
};
/**
* 副本
*/
const modelReplicas = ref(false);
const channelIdReplicas = ref('');
const channelTypeReplicas = ref(0);
const onReplicas = (row: any) => {
channelIdReplicas.value = row.channel_id;
channelTypeReplicas.value = row.channel_type;
modelReplicas.value = true;
};
/**
* 消息
* @param row
*/
const onMessage = (row: any) => {
router.push({
path: '/data/message',
query: { channel_id: row.channel_id, channel_type: row.channel_type }
});
};
onMounted(() => {
getNodes();
});
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
<template #action="{ row }">
<el-space>
<el-button :type="row.active === 1 ? 'danger' : 'primary'" link @click="onStartOrStop(row)">
{{ row.active === 1 ? '停止' : '开始' }}
</el-button>
<el-button type="primary" link @click="onMigrate(row)">迁移</el-button>
<el-button type="primary" link @click="onClusterConfig(row)">配置</el-button>
<el-button type="primary" link @click="onReplicas(row)">副本</el-button>
<el-button type="primary" link @click="onMessage(row)">消息</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
<!-- 迁移 -->
<Migrate
v-model="modelMigrate"
:channel-id="channelIdMigrate"
:channel-type="channelTypeMigrate"
:replicas="replicasMigrate"
@submit="onSubmit"
/>
<!-- 配置 -->
<ClusterConfig
v-model="modelClusterConfig"
:channel-id="channelIdClusterConfig"
:channel-type="channelTypeClusterConfig"
:node-id="nodeIdClusterConfig"
/>
<!-- 副本 -->
<Replicas v-model="modelReplicas" :channel-id="channelIdReplicas" :channel-type="channelTypeReplicas" />
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 频道
</route>

View File

@@ -1,152 +0,0 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { useRouter } from 'vue-router';
const router = useRouter();
/**
* 表格
*/
const tableRef = ref<VxeGridInstance<any>>();
const toolNum = ref(0);
const loadList = async () => {
const res = await clusterApi.nodes();
toolNum.value = res.total;
return res.data;
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54, fixed: 'left' },
{ field: 'id', title: '节点ID', minWidth: 80, fixed: 'left' },
{
field: 'role',
title: '角色',
minWidth: 120,
formatter({ row }) {
if (row.is_leader == 1) {
return '领导';
}
if (row.role == 1) {
return '代理';
}
return '副本';
}
},
{ field: 'term', title: '任期', minWidth: 100 },
{
field: 'slot_leader_count',
title: '槽领导/槽数量',
minWidth: 120,
formatter({ row }) {
return `${row.slot_leader_count}/${row.slot_count}`;
}
},
{
field: 'allow_vote',
title: '投票权',
minWidth: 100,
formatter({ cellValue }) {
return cellValue === 1 ? '有' : '无';
}
},
{
field: 'online',
title: '在线',
minWidth: 100,
formatter({ cellValue }) {
return cellValue === 1 ? '在线' : '离线';
}
},
{
field: 'offline_count',
title: '离线次数',
minWidth: 100,
formatter({ row }) {
return row.offline_count > 0 ? `${row.offline_count}(${row.last_offline})` : `0`;
}
},
{ field: 'uptime', title: '运行时间', minWidth: 120 },
{ field: 'cluster_addr', title: '地址', minWidth: 160 },
{ field: 'app_version', title: '程序版本', minWidth: 120 },
{ field: 'config_version', title: '配置版本', minWidth: 120 },
{ field: 'status_format', title: '状态', minWidth: 100 },
{ field: 'action', title: '操作', width: 60, fixed: 'right', slots: { default: 'action' } }
]
});
/**
* 日志
* @param row
*/
const onLog = (row: any) => {
router.push({
path: '/cluster/log',
query: {
node_id: row.id,
}
})
};
</script>
<template>
<wk-page class="flex-col">
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-text type="primary" tag="b">共计节点总数{{ toolNum }}</el-text>
</template>
<template #action="{ row }">
<el-space>
<el-button type="primary" link @click="onLog(row)">日志</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 节点
</route>

View File

@@ -1,179 +0,0 @@
<script name="MigrateSlot" lang="ts" setup>
// API 接口
import { clusterApi } from '@/api/modules/cluster-api.ts';
import { ElMessage } from 'element-plus';
import type { VxeFormProps } from 'vxe-pc-ui';
interface IProps {
slot: number;
replicas: number[];
}
interface IReplicasItem {
value: number;
label: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
slot: 0,
replicas: []
});
const showModel = defineModel<boolean>();
const emits = defineEmits(['submit']);
const loadingModel = ref(false);
const replicasList = ref<IReplicasItem[]>([]);
watch(
() => showModel.value,
val => {
if (val) {
if (props.slot) {
getNodes();
const getReplicas: IReplicasItem[] = [];
props.replicas.map(item => {
getReplicas.push({
value: item,
label: item
});
});
replicasList.value = getReplicas;
formOptions.data = {
...formOptions.data,
slot: props.slot
} as FormDataVO;
}
} else {
formOptions.data = {
slot: 0,
migrateFrom: null,
migrateTo: null
};
}
}
);
const nodeList = ref();
const getNodes = async () => {
const nodes: any[] = [];
const res = await clusterApi.simpleNodes();
if (res.data.length > 0) {
res.data.map((item: any) => {
nodes.push({
value: item.id,
label: item.id
});
});
}
nodeList.value = nodes;
};
/**
* 表单
*/
interface FormDataVO {
slot: number;
migrateFrom: number | null;
migrateTo: number | null;
}
const formRef = ref();
const formOptions = reactive<VxeFormProps<FormDataVO>>({
loading: false,
data: {
slot: 0,
migrateFrom: null,
migrateTo: null
},
rules: {
migrateFrom: [{ required: true, message: '请选择副本节点' }],
migrateTo: [{ required: true, message: '请选择迁移至节点' }]
},
titleBold: true,
titleWidth: 100,
titleColon: true,
titleOverflow: true,
items: [
{
field: 'migrateFrom',
title: '副本节点',
span: 24,
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择副本节点'
},
options: replicasList
}
},
{
field: 'migrateTo',
title: '迁移至节点',
span: 24,
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择迁移至节点'
},
options: nodeList
}
}
]
});
// 提交功能
const onFuncConfirm = async () => {
const validate = await formRef.value.validate();
if (validate) return;
clusterApi.migrateSlot(formOptions.data as any).then(() => {
ElMessage({
message: '操作成功!',
type: 'success',
plain: true
});
emits('submit');
});
};
onMounted(() => {
getNodes();
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="迁移"
:width="460"
:height="360"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
show-footer
show-confirm-button
show-cancel-button
@confirm="onFuncConfirm"
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表单 -->
<div class="flex-1 card overflow-hidden">
<vxe-form ref="formRef" v-bind="formOptions"></vxe-form>
</div>
<!-- E 表单 -->
</wk-page>
</vxe-modal>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,141 +0,0 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import { ElMessage } from 'element-plus';
import MigrateSlot from './components/MigrateSlot.vue';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/modules/user';
import { ActionWrite } from '@/constants';
const router = useRouter();
const userStore = useUserStore();
/**
* 表格
*/
const tableRef = ref<VxeGridInstance<any>>();
const toolNum = ref(0);
const loadList = async () => {
const res = await clusterApi.slots();
toolNum.value = res.total;
return res.data;
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54, fixed: 'left' },
{ field: 'id', title: '分区(槽)', minWidth: 120, fixed: 'left' },
{ field: 'leader_id', title: '领导节点', minWidth: 120 },
{ field: 'term', title: '任期', minWidth: 160 },
{ field: 'replicas', title: '副本节点', minWidth: 120 },
{ field: 'channel_count', title: '频道数量', sortable: true, minWidth: 120 },
{ field: 'log_index', title: '日志高度', sortable: true, minWidth: 120 },
{ field: 'status_format', title: '状态', minWidth: 100 },
{ field: 'action', title: '操作', width: 100, fixed: 'right', slots: { default: 'action' } }
]
});
/**
* 迁移
*/
const modelMigrateSlot = ref(false);
const slotMigrateSlot = ref(0);
const replicasMigrateSlot = ref([]);
const onMigrateSlot = (row: any) => {
if (!userStore.hasPermission('slotMigrate', ActionWrite)) {
return ElMessage({
message: '没有操作权限!',
type: 'warning',
plain: true
});
}
slotMigrateSlot.value = row.id;
replicasMigrateSlot.value = row.replicas;
modelMigrateSlot.value = true;
};
const onSubmit = () => {
tableRef.value?.commitProxy('query');
};
/**
* 日志
* @param row
*/
const onLog = (row: any) => {
router.push({
path: '/cluster/log',
query: {
slot: row.id,
node_id: row.leader_id,
log_type: 2
}
});
};
</script>
<template>
<wk-page class="flex-col">
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-text type="primary" tag="b">共计节点总数{{ toolNum }}</el-text>
</template>
<template #action="{ row }">
<el-space>
<el-button type="primary" link @click="onMigrateSlot(row)">迁移</el-button>
<el-button type="primary" link @click="onLog(row)">日志</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
<!-- 迁移 -->
<MigrateSlot v-model="modelMigrateSlot" :slot="slotMigrateSlot" :replicas="replicasMigrateSlot" @submit="onSubmit" />
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 分区
</route>

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import API from '../../services/API';
import { onMounted, ref } from 'vue';
const channelTotal = ref<any>({}); // 频道列表
const currentUids = ref<string[]>() // 当前用户列表
const loadingOfSubscribers = ref<boolean>(false) // 是否正在加载订阅者
const loadingOfDenylist = ref<boolean>(false) // 是否正在加载黑名单
const loadingOfAllowlist = ref<boolean>(false) // 是否正在加载白名单
const channelType = ref<number>(0) // 频道类型
const channelId = ref<string>() // 频道ID
const currentPage = ref(1); // 当前页
const pageSize = ref(20); // 每页显示数量
const offsetCreatedAt = ref("0"); // 偏移量
const pre = ref<boolean>() // 是否向上分页
const hasNext = ref<boolean>(true) // 是否有下一页
const hasPrev = ref<boolean>(false) // 是否有上一页
onMounted(() => {
searchChannels()
})
const searchChannels = () => {
API.shared.searchChannels({
channelId: channelId.value,
channelType: channelType.value,
limit: pageSize.value,
offsetCreatedAt: offsetCreatedAt.value,
pre: pre.value
}).then((res) => {
channelTotal.value = res
hasNext.value = channelTotal.value?.more === 1
hasPrev.value = currentPage.value > 1
if(pre.value) { // 如果是向上翻页,则有下页数据
hasNext.value = true
}
}).catch((err) => {
alert(err)
})
}
const getSubscribers = (channelId: string, channelType: number) => {
loadingOfSubscribers.value = true;
return API.shared.subscribers(channelId, channelType).then((res) => {
currentUids.value = res
}).catch((err) => {
alert(err)
}).finally(() => {
loadingOfSubscribers.value = false;
})
}
const getDenylist = (channelId: string, channelType: number) => {
loadingOfDenylist.value = true;
return API.shared.denylist(channelId, channelType).then((res) => {
currentUids.value = res
}).catch((err) => {
alert(err)
}).finally(() => {
loadingOfDenylist.value = false;
})
}
const getAllowlist = (channelId: string, channelType: number) => {
loadingOfAllowlist.value = true;
return API.shared.allowlist(channelId, channelType).then((res) => {
currentUids.value = res
}).catch((err) => {
alert(err)
}).finally(() => {
loadingOfAllowlist.value = false;
})
}
// 显示订阅者
const onShowSubscriber = (channelId: string, channelType: number) => {
getSubscribers(channelId, channelType).then(() => {
const dialog = document.getElementById('userlist') as HTMLDialogElement;
dialog.showModal();
})
}
const onShowDenylist = (channelId: string, channelType: number) => {
getDenylist(channelId, channelType).then(() => {
const dialog = document.getElementById('userlist') as HTMLDialogElement;
dialog.showModal();
})
}
const onShowAllowlist = (channelId: string, channelType: number) => {
getAllowlist(channelId, channelType).then(() => {
const dialog = document.getElementById('userlist') as HTMLDialogElement;
dialog.showModal();
})
}
const onChannelTypeSearch = (e: any) => {
channelType.value = e.target.value
searchChannels()
}
const onChannelIdSearch = (e: any) => {
channelId.value = e.target.value
searchChannels()
}
const prevPage = () => {
if (currentPage.value <= 1) {
hasPrev.value = false
return
}
hasPrev.value = true
currentPage.value -= 1
pre.value = true
if (channelTotal.value?.data?.length > 0) {
offsetCreatedAt.value = channelTotal.value.data[0].created_at
}
searchChannels()
}
const nextPage = () => {
if (channelTotal.value?.more === 0 && !pre.value) {
return
}
currentPage.value += 1
pre.value = false
if (channelTotal.value?.data?.length > 0) {
offsetCreatedAt.value = channelTotal.value.data[channelTotal.value.data.length - 1].created_at
}
searchChannels()
}
</script>
<template>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<!-- 频道类型 -->
<div class="text-sm ml-10">
<label>频道</label>
<select class="select select-bordered max-w-xs select-sm w-20 ml-2"
v-on:change="onChannelTypeSearch" v-model="channelType">
<option value="0">所有</option>
<option value="1">个人</option>
<option value="2">群聊</option>
<option value="3">客服</option>
<option value="4">社区</option>
<option value="5">话题</option>
<option value="6">资讯</option>
<option value="7">数据</option>
</select>
<input type="text" placeholder="频道ID" class="input input-bordered select-sm ml-2"
v-on:change="onChannelIdSearch" />
</div>
</div>
<table class="table mt-10 table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>
<div class="flex items-center">
频道ID
</div>
</th>
<th>
<div class="flex items-center">
频道类型
</div>
</th>
<th>
<div class="flex items-center">
订阅者数量
</div>
</th>
<th>
<div class="flex items-center">
黑名单数量
</div>
</th>
<th>
<div class="flex items-center">
白名单数量
</div>
</th>
<th>
<div class="flex items-center">
状态
</div>
</th>
<th>
<div class="flex items-center">
最大序号
</div>
</th>
<th>
<div class="flex items-center">
最后消息时间
</div>
</th>
<th>
<div class="flex items-center">
槽位
</div>
</th>
<th>
<div class="flex items-center">
创建时间
</div>
</th>
<th>
<div class="flex items-center">
更新时间
</div>
</th>
<th>
<div class="flex items-center">
操作
</div>
</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="channel in channelTotal.data">
<td>
{{ channel.channel_id }}
</td>
<td>{{ channel.channel_type }}</td>
<td>{{ channel.subscriber_count }}</td>
<td>{{ channel.denylist_count }}</td>
<td>{{ channel.allowlist_count }}</td>
<td>{{ channel.status_format }}</td>
<td>{{ channel.last_msg_seq }}</td>
<td>{{ channel.last_msg_time_format }}</td>
<td>{{ channel.slot }}</td>
<td>{{ channel.created_at_format }}</td>
<td>{{ channel.updated_at_format }}</td>
<td class="flex">
<button class="btn btn-link btn-sm"
v-on:click="() => { onShowSubscriber(channel.channel_id, channel.channel_type) }">订阅者</button>
<button class="btn btn-link btn-sm"
v-on:click="() => {
onShowAllowlist(channel.channel_id, channel.channel_type)
}">白名单</button>
<button class="btn btn-link btn-sm"
v-on:click="() => {
onShowDenylist(channel.channel_id, channel.channel_type)
}">黑名单</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end mt-10 mr-10">
<div className="join">
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasPrev }" v-on:click="prevPage">«</button>
<button className="join-item btn">{{ currentPage }}</button>
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasNext }" v-on:click="nextPage">»</button>
</div>
</div>
<dialog id="userlist" class="modal">
<div class="modal-box flex flex-wrap gap-2">
<div v-if="currentUids?.length == 0">无数据</div>
<a class="link" v-for="uid in currentUids">{{ uid }}</a>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -1,197 +1,312 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import { dataApi } from '@/api/modules/data-api';
import { onMounted, onBeforeUnmount, ref } from 'vue';
const loading = ref<boolean>(false);
import API from '../../services/API';
import { formatMemory } from '../../services/Utils';
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
const nodeTotal = ref<any>({}); // 节点列表
const selectedNodeId = ref<number>(1) // 选中的节点ID
const connectionTotal = ref<any>({}); // 连接列表
const sortField = ref<string>('id') // 排序字段
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
const uidSearch = ref<string>('') // 用户UID搜索
/**
* 查询条件
* */
const nodeList = ref<any[]>([]);
const formOptions = reactive<VxeFormProps<any>>({
data: {
uid: '',
node_id: null,
sort: 'id',
limit: 100
},
items: [
{
field: 'node_id',
title: '节点',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: nodeList
}
},
{
field: 'uid',
title: '用户UID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入用户UID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
const getNodes = async () => {
const nodes: any[] = [];
const res = await clusterApi.simpleNodes();
if (res.data.length > 0) {
res.data.map((item: any) => {
nodes.push({
value: item.id,
label: item.id
});
});
}
nodeList.value = nodes;
if (nodes.length > 0) {
formOptions.data = {
...formOptions.data,
node_id: nodes[0].value
};
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const toolNum = ref(0);
const loadList = async (query: any) => {
const res = await dataApi.connections({ ...query });
if (res.connections) {
toolNum.value = res.total;
return res.connections;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
autoLoad: false,
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'id', title: '连接ID', minWidth: 120 },
{ field: 'uid', title: '用户UID', minWidth: 120 },
{ field: 'in_msgs', title: '发出消息数', minWidth: 100 },
{ field: 'out_msgs', title: '收到消息数', minWidth: 100 },
{ field: 'in_msg_bytes', title: '发出消息字节数', minWidth: 120, formatter: 'formatMemory' },
{ field: 'out_msg_bytes', title: '收到消息字节数', minWidth: 120, formatter: 'formatMemory' },
{ field: 'in_packets', title: '发出报文数', minWidth: 100 },
{ field: 'out_packets', title: '收到报文数', minWidth: 100 },
{ field: 'in_packet_bytes', title: '发出报文字节数', minWidth: 120, formatter: 'formatMemory' },
{ field: 'out_packet_bytes', title: '收到报文字节数', minWidth: 120, formatter: 'formatMemory' },
{
field: 'address',
title: '连接地址',
minWidth: 180,
formatter({ row }) {
return `${row.ip}:${row.port}`;
}
},
{ field: 'uptime', title: '存活时间', minWidth: 120 },
{ field: 'idle', title: '空闲时间', minWidth: 120 },
{ field: 'version', title: '协议版本', minWidth: 100 },
{ field: 'device', title: '设备', minWidth: 100 },
{ field: 'device_id', title: '设备编号', minWidth: 290 },
{ field: 'proxy_type_format', title: '代理类型', minWidth: 100 },
{ field: 'leader_id', title: '领导节点', minWidth: 100 }
]
});
let connIntervalId: number;
onMounted(() => {
getNodes();
});
API.shared.simpleNodes().then((res) => {
nodeTotal.value = res
if (nodeTotal.value.data.length > 0) {
selectedNodeId.value = nodeTotal.value.data[0].id
}
loadConnections()
startRequestConns()
}).catch((err) => {
alert(err)
})
})
onBeforeUnmount(() => {
window.clearInterval(connIntervalId)
})
const onNodeChange = (e: any) => {
selectedNodeId.value = e.target.value
loadConnections(e.target.value)
}
const loadConnections = (closeLoading?: boolean) => {
if (!closeLoading) {
loading.value = true;
}
API.shared.connections(selectedNodeId.value, 100,sortField.value,uidSearch.value).then((res) => {
connectionTotal.value = res
}).catch((err) => {
alert(err)
}).finally(() => {
if (!closeLoading) {
loading.value = false;
}
})
}
const startRequestConns = async () => {
connIntervalId = window.setInterval(async () => {
loadConnections(true)
}, 1000)
}
const onSort = (s: string) => {
if (sortField.value == s) {
if (s.endsWith("Desc")) {
sortField.value = s.substring(0, s.length - 4)
} else {
sortField.value = s + "Desc"
}
} else {
sortField.value = s
}
loadConnections()
}
const onUidSearch = (e: any) => {
uidSearch.value = e.target.value
loadConnections()
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex">
<div class="text-sm ml-3">
<label>节点</label>
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onNodeChange">
<option v-for="node in nodeTotal.data" :selected="node.id == selectedNodeId">{{ node.id }}</option>
</select>
</div>
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"
>total
<template #tools>
<el-text type="primary" tag="b">共计节点总数{{ toolNum }}</el-text>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<div class="text-sm ml-10">
<label>用户UID</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2" v-on:change="onUidSearch" />
</div>
</div>
<table class="table mt-10 table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>
<div class="flex items-center">
连接ID
<a href="#" v-on:click="() => onSort('id')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
<style scoped lang="scss"></style>
</th>
<th>
<div class="flex items-center">
用户UID
</div>
</th>
<th>
<div class="flex items-center">
发出消息数
<a href="#" v-on:click="() => onSort('inMsg')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
收到消息数
<a href="#" v-on:click="() => onSort('outMsg')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
发出消息字节数
<a href="#" v-on:click="() => onSort('inMsgBytes')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
收到消息字节数
<a href="#" v-on:click="() => onSort('outMsgBytes')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
<route lang="yaml">
meta:
title: 连接
</route>
</th>
<th>
<div class="flex items-center">
发出报文数
<a href="#" v-on:click="() => onSort('inPacket')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
收到报文数
<a href="#" v-on:click="() => onSort('outPacket')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
发出报文字节数
<a href="#" v-on:click="() => onSort('inPacketBytes')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
收到报文字节数
<a href="#" v-on:click="() => onSort('outPacketBytes')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
连接地址
</div>
</th>
<th>
<div class="flex items-center">
存活时间
<a href="#" v-on:click="() => onSort('uptime')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
空闲时间
<a href="#" v-on:click="() => onSort('idle')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>
<div class="flex items-center">
协议版本
<a href="#" v-on:click="() => onSort('protoVersion')">
<svg class="w-3 h-3 ms-1.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 24 24">
<path
d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z" />
</svg>
</a>
</div>
</th>
<th>设备</th>
<th>设备编号</th>
<th>代理类型</th>
<th>领导节点</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="conn in connectionTotal.connections">
<td class="text-blue-800">{{ conn.id }}</td>
<td>{{ conn.uid }}</td>
<td>{{ conn.in_msgs }}</td>
<td>{{ conn.out_msgs }}</td>
<td>{{ formatMemory(conn.in_msg_bytes) }}</td>
<td>{{ formatMemory(conn.out_msg_bytes) }}</td>
<td>{{ conn.in_packets }}</td>
<td>{{ conn.out_packets }}</td>
<td>{{ formatMemory(conn.in_packet_bytes) }}</td>
<td>{{ formatMemory(conn.out_packet_bytes) }}</td>
<td>{{ `${conn.ip}:${conn.port}` }}</td>
<td>{{ conn.uptime }}</td>
<td>{{ conn.idle }}</td>
<td>{{ conn.version }}</td>
<td>{{ conn.device }}</td>
<td>{{ conn.device_id }}</td>
<td>{{conn.proxy_type_format}}</td>
<td>{{conn.leader_id}}</td>
</tr>
</tbody>
</table>
<div class="flex flex-col gap-4 w-full mt-2" v-if="loading">
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
</div>
</div>
<div class="flex justify-end mt-10 mr-10">
<div class="flex pr-4">
<div class="flex items-center">
<ul class="text-sm">
<li>
总数量: <span class="text-blue-800">{{ connectionTotal.total }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,136 +1,109 @@
<script setup lang="ts">
// API 接口
import { dataApi } from '@/api/modules/data-api';
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { useRouter } from "vue-router";
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
const conversationTotal = ref<any>({}); // 会话列表
const uid = ref<string>(); // 用户UID
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
const router = useRouter()
import { useRoute } from 'vue-router';
const query = router.currentRoute.value.query; //查询参数
const route = useRoute();
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
uid: ''
},
items: [
{
field: 'uid',
title: '用户UID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入用户UID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const loadList = async (query: any) => {
const res = await dataApi.conversations({ ...query });
if (res.data) {
return res.data;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'uid', title: '用户UID', minWidth: 220 },
{ field: 'type_format', title: '会话类型', minWidth: 120 },
{ field: 'channel_id', title: '频道ID', minWidth: 220 },
{ field: 'channel_type_format', title: '频道类型', minWidth: 120 },
{ field: 'last_msg_seq', title: '最新消息序号', minWidth: 120 },
{ field: 'readed_to_msg_seq', title: '已读消息序号', minWidth: 120 },
{ field: 'unread_count', title: '未读数量', minWidth: 120 },
{ field: 'updated_at_format', title: '最后会话时间', minWidth: 140 }
]
});
if(query.uid){
uid.value = query.uid as string
}
onMounted(() => {
if (route.query?.uid) {
formOptions.data = {
uid: route.query.uid
};
}
});
searchConversation()
})
const searchConversation = () => {
API.shared.conversations({uid:uid.value}).then((res) => {
conversationTotal.value = res
}).catch((err) => {
alert(err)
})
}
const onUidSearch = (e: any) => {
uid.value = e.target.value
searchConversation()
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<div class="text-sm ml-10">
<label>用户UID</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onUidSearch" v-model="uid"/>
</div>
</div>
<table class="table mt-10 table-pin-rows">
<thead>
<tr>
<th>
<div class="flex items-center">
用户UID
</div>
</th>
<th>
<div class="flex items-center">
会话类型
</div>
</th>
<th>
<div class="flex items-center">
频道ID
</div>
</th>
<th>
<div class="flex items-center">
频道类型
</div>
</th>
<th>
<div class="flex items-center">
最新消息序号
</div>
</th>
<th>
<div class="flex items-center">
已读消息序号
</div>
</th>
<th>
<div class="flex items-center">
未读数量
</div>
</th>
<th>
<div class="flex items-center">
最后会话时间
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="conversation in conversationTotal.data">
<td> {{ conversation.uid }}</td>
<td>{{conversation.type_format}}</td>
<td>{{conversation.channel_id}}</td>
<td>{{conversation.channel_type_format}}</td>
<td>{{conversation.last_msg_seq}}</td>
<td>{{conversation.readed_to_msg_seq}}</td>
<td>{{conversation.unread_count}}</td>
<td>{{conversation.updated_at_format}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 会话
</route>
</template>

View File

@@ -1,180 +1,190 @@
<script setup lang="ts">
// API 接口
import { dataApi } from '@/api/modules/data-api';
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { useRouter } from "vue-router";
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { useRoute } from 'vue-router';
const route = useRoute();
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
uid: '',
channel_type: null,
channel_id: null,
offset_created_at: 0,
limit: 20,
pre: 0
},
items: [
{
field: 'uid',
title: '用户UID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入用户UID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const deviceTotal = ref<any>({}); // 设备列表
const uid = ref<string>(''); // 用户UID
const offsetCreatedAt = ref(0); // 偏移量
const pre = ref<boolean>() // 是否向上分页
const pageSize = ref(20); // 每页显示数量
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有一页
const hasNext = ref<boolean>(true); // 是否有一页
const hasNext = ref<boolean>(true) // 是否有一页
const hasPrev = ref<boolean>(false) // 是否有一页
const loadList = async (query: any) => {
const res = await dataApi.devices({ ...query });
if (res.data) {
hasNext.value = res.more !== 1;
hasPrev.value = currentPage.value <= 1;
return res.data;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ field: 'uid', title: '所属用户', minWidth: 220 },
{ field: 'device_flag_format', title: '设备标识', minWidth: 120 },
{ field: 'device_level_format', title: '设备等级', minWidth: 120 },
{ field: 'token', title: '设备Token', minWidth: 120 },
{ field: 'conn_count', title: '连接数', minWidth: 160 },
{ field: 'send_msg_count', title: '发出消息数', minWidth: 120 },
{ field: 'recv_msg_count', title: '接受消息数', minWidth: 120 },
{ field: 'send_msg_bytes', title: '发出消息大小', minWidth: 140 },
{ field: 'recv_msg_bytes', title: '接受消息大小', minWidth: 140 },
{ field: 'created_at_format', title: '创建时间', minWidth: 140 },
{ field: 'updated_at_format', title: '更新时间', minWidth: 140 }
]
});
const router = useRouter()
/**
* 分页切换
*/
const onPage = (type: 0 | 1) => {
// 下一页
if (type === 0) {
currentPage.value = currentPage.value + 1;
}
const query = router.currentRoute.value.query; //查询参数
// 上一页
if (type === 1 && currentPage.value > 1) {
currentPage.value = currentPage.value - 1;
}
if (query.uid) {
uid.value = query.uid as string
}
console.log(currentPage.value);
formOptions.data = {
...formOptions.data,
pre: type
};
tableRef.value?.commitProxy('query');
};
onMounted(() => {
if (route.query?.uid) {
formOptions.data = {
...formOptions.data,
uid: route.query.uid
};
}
searchDevice()
});
const searchDevice = () => {
API.shared.devices({
uid: uid.value,
offsetCreatedAt: offsetCreatedAt.value,
pre: pre.value,
limit: pageSize.value
}).then((res) => {
deviceTotal.value = res
hasNext.value = deviceTotal.value?.more === 1
hasPrev.value = currentPage.value > 1
if(pre.value) { // 如果是向上翻页,则有下页数据
hasNext.value = true
}
}).catch((err) => {
alert(err)
})
}
const onUidSearch = (e: any) => {
uid.value = e.target.value
searchDevice()
}
const prevPage = () => {
if (currentPage.value <= 1) {
hasPrev.value = false
return
}
hasPrev.value = true
currentPage.value -= 1
pre.value = true
if (deviceTotal.value?.data?.length > 0) {
offsetCreatedAt.value = deviceTotal.value.data[0].created_at
}
searchDevice()
}
const nextPage = () => {
if (deviceTotal.value?.more === 0 && !pre.value) {
return
}
currentPage.value += 1
pre.value = false
if (deviceTotal.value?.data?.length > 0) {
offsetCreatedAt.value = deviceTotal.value.data[deviceTotal.value.data.length - 1].created_at
}
searchDevice()
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<div class="text-sm ml-4">
<label>用户UID</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onUidSearch" v-model="uid" />
</div>
</div>
<table class="table mt-5 table-pin-rows">
<thead>
<tr>
<th>
<div class="flex items-center">
所属用户
</div>
</th>
<th>
<div class="flex items-center">
设备标识
</div>
</th>
<th>
<div class="flex items-center">
设备等级
</div>
</th>
<th>
<div class="flex items-center">
设备Token
</div>
</th>
<!-- <th>
<div class="flex items-center">
连接数
</div>
</th>
<th>
<div class="flex items-center">
发出消息数
</div>
</th>
<th>
<div class="flex items-center">
接受消息数
</div>
</th>
<th>
<div class="flex items-center">
发出消息大小
</div>
</th>
<th>
<div class="flex items-center">
接受消息大小
</div>
</th> -->
<th>
<div class="flex items-center">
创建时间
</div>
</th>
<th>
<div class="flex items-center">
更新时间
</div>
</th>
<!-- <th>
<div class="flex items-center">
操作
</div>
</th> -->
</tr>
</thead>
<tbody>
<tr v-for="device in deviceTotal.data">
<td>
{{ device.uid }}
</td>
<td>{{ device.device_flag_format }}</td>
<td>{{ device.device_level_format }}</td>
<td>{{ device.token }}</td>
<!-- <td>{{ device.conn_count }}</td>
<td>{{ device.send_msg_count }}</td>
<td>{{ device.recv_msg_count }}</td>
<td>{{ device.send_msg_bytes }}</td>
<td>{{ device.recv_msg_bytes }}</td> -->
<td>{{ device.created_at_format }}</td>
<td>{{ device.updated_at_format }}</td>
<!-- <td class="flex">
<button class="btn btn-sm btn-warning">踢出</button>
</td> -->
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end mt-10 mr-10">
<div className="join">
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasPrev }" v-on:click="prevPage">«</button>
<button className="join-item btn">{{ currentPage }}</button>
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasNext }" v-on:click="nextPage">»</button>
</div>
</div>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 设备
</route>
</template>

View File

@@ -1,273 +1,345 @@
<script setup lang="ts">
// API 接口
import { dataApi } from '@/api/modules/data-api';
// 常量
import { CHANNEL_TYPE } from '@/constants';
import { base64Decode } from '@/utils';
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { ellipsis, base64Decode, base64Encode } from '../../services/Utils';
import { useRouter } from "vue-router";
import App from '../../services/App';
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
const router = useRouter()
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
const nodeTotal = ref<any>({}); // 节点列表
const selectedNodeId = ref<number>() // 选中的节点ID
const loading = ref<boolean>(false);
const messages = ref<any>({}); // 消息列表
const fromUid = ref<string>() // 发送者
const channelId = ref<string>() // 接受频道id
const channelType = ref<number>() // 频道类型
const payload = ref<string>() // 消息内容
const messageId = ref<number>() // 消息id
const clientMsgNo = ref<string>() // 客户端唯一编号
import { useRoute, useRouter } from 'vue-router';
const currentPage = ref<number>(1) // 当前页码
const pageSize = ref<number>(20) // 每页数量
const route = useRoute();
const router = useRouter();
const offsetMessageId = ref<number>() // 偏移的messageId
const offsetMessageSeq = ref<number>() // 偏移的messageSeq
const pre = ref<boolean>() // 是否向上分页
const content = ref<string>() // 当前要显示的内容
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
node_id: '',
from_uid: '',
channel_type: null,
channel_id: null,
payload: '',
message_id: '',
offset_message_id: 0,
offset_message_seq: 0,
client_msg_no: '',
limit: 20,
pre: 0
},
items: [
{
field: 'from_uid',
title: '发送者',
itemRender: { name: 'ElInput', props: { placeholder: '请输入发送者' } }
},
{
field: 'channel_type',
title: '频道类型',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择频道类型',
style: { width: '180px' }
},
options: CHANNEL_TYPE
}
},
{
field: 'channel_id',
title: '频道ID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入频道ID' } }
},
{
field: 'message_id',
title: '消息ID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入消息ID' } }
},
{
field: 'payload',
title: '消息内容',
itemRender: { name: 'ElInput', props: { placeholder: '请输入消息内容' } }
},
{
field: 'client_msg_no',
title: '客户端编号',
itemRender: { name: 'ElInput', props: { placeholder: '请输入客户端编号' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有上一页
const hasNext = ref<boolean>(true); // 是否有下一页
const loadList = async (query: any) => {
const res = await dataApi.searchMessages({ ...query });
if (res.data) {
hasNext.value = res.data.length < formOptions.data.limit;
hasPrev.value = currentPage.value <= 1;
return res.data;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'message_id', title: '消息ID', minWidth: 140 },
{ field: 'message_seq', title: '消息序号', minWidth: 100 },
{ field: 'from_uid', title: '发送者', minWidth: 100 },
{ field: 'channel_id', title: '接受频道', minWidth: 100 },
{
field: 'channel_type',
title: '接受频道类型',
minWidth: 80,
formatter({ cellValue }) {
const item = CHANNEL_TYPE.find(item => item.value === cellValue);
return item ? item.label : '';
}
},
{
field: 'payload',
title: '消息内容',
minWidth: 280,
formatter({ cellValue }) {
return base64Decode(cellValue);
}
},
{ field: 'timestamp_format', title: '发送时间', minWidth: 120 },
{ field: 'client_msg_no', title: '客户端唯一编号', minWidth: 240 },
{ field: 'action', title: '操作', width: 90, fixed: 'right', slots: { default: 'action' } }
]
});
/**
* 分页切换
*/
const onPage = (type: 0 | 1) => {
let offset_message_id = 0;
let offset_message_seq = 0;
const tableData = tableRef.value?.getData();
// 下一页
if (type === 0) {
currentPage.value = currentPage.value + 1;
if (tableData && tableData.length > 0) {
offset_message_id = tableData[tableData.length - 1].message_id;
offset_message_seq = tableData[tableData.length - 1].message_seq;
}
}
// 上一页
if (type === 1 && currentPage.value > 1) {
currentPage.value = currentPage.value - 1;
if (tableData && tableData.length > 0) {
offset_message_id = tableData[0].message_id;
offset_message_seq = tableData[0].message_seq;
}
}
console.log(currentPage.value);
formOptions.data = {
...formOptions.data,
offset_message_id,
offset_message_seq,
pre: type
};
tableRef.value?.commitProxy('query');
};
const onPageTrace = (row: any) => {
router.push({
path: '/monitor/trace',
query: {
clientMsgNo: row.client_msg_no
}
});
};
const query = router.currentRoute.value.query; //查询参数
onMounted(() => {
// 频道类型
if (route.query?.channel_type) {
formOptions.data = {
...formOptions.data,
channel_type: Number(route.query.channel_type)
};
}
// 频道ID
if (route.query?.channel_id) {
formOptions.data = {
...formOptions.data,
channel_id: route.query.channel_id
};
}
});
App.shard().loadSystemSettingIfNeed()
if (query.channelId) {
channelId.value = query.channelId as string
}
if (query.channelType) {
channelType.value = parseInt(query.channelType as string)
}
searchMessages()
API.shared.simpleNodes().then((res) => {
nodeTotal.value = res
}).catch((err) => {
alert(err)
})
})
const searchMessages = () => {
loading.value = true;
let base64EncodePayload: string = ''
if (payload.value && payload.value.trim() != '') {
base64EncodePayload = base64Encode(payload.value)
console.log(base64EncodePayload)
base64EncodePayload = encodeURIComponent(base64EncodePayload)
}
API.shared.searchMessages({
nodeId: selectedNodeId.value,
fromUid: fromUid.value,
channelId: channelId.value,
channelType: channelType.value,
payload: base64EncodePayload,
messageId: messageId.value,
limit: pageSize.value,
offsetMessageId: offsetMessageId.value,
offsetMessageSeq: offsetMessageSeq.value,
pre: pre.value,
clientMsgNo: clientMsgNo.value
}).then((res) => {
messages.value = res.data
}).catch((err) => {
alert(err)
}).finally(() => {
loading.value = false;
})
}
// const onNodeChange = (e: any) => {
// selectedNodeId.value = e.target.value
// searchMessages()
// }
const onFromUidSearch = (e: any) => {
fromUid.value = e.target.value
resetFilter()
searchMessages()
}
const onChannelIdSearch = (e: any) => {
channelId.value = e.target.value
if (!channelId.value || channelId.value.trim() == '') {
channelType.value = 0
}
resetFilter()
searchMessages()
}
const onChannelTypeSearch = (e: any) => {
channelType.value = e.target.value
if (!channelId.value || channelId.value.trim() == '') {
return
}
resetFilter()
searchMessages()
}
const onMessageIdSearch = (e: any) => {
messageId.value = e.target.value
resetFilter()
searchMessages()
}
const onPayloadSearch = (e: any) => {
payload.value = e.target.value
resetFilter()
searchMessages()
}
const onClientMsgNoSearch = (e: any) => {
clientMsgNo.value = e.target.value
resetFilter()
searchMessages()
}
const resetFilter = () => {
currentPage.value = 1
offsetMessageId.value = 0
offsetMessageSeq.value = 0
}
// 下一页
const nextPage = () => {
if (messages.value.length < pageSize.value) {
alert("没有更多数据了")
return
}
currentPage.value += 1
offsetMessageId.value = messages.value[messages.value.length - 1].message_id
offsetMessageSeq.value = messages.value[messages.value.length - 1].message_seq
pre.value = false
searchMessages()
}
// 上一页
const prevPage = () => {
if (currentPage.value <= 1) {
return
}
currentPage.value -= 1
offsetMessageId.value = messages.value[0].message_id
offsetMessageSeq.value = messages.value[0].message_seq
pre.value = true
searchMessages()
}
// 显示消息内容
const onShowMessageContent = (message: any) => {
content.value = base64Decode(message.payload)
const dialog = document.getElementById('content') as HTMLDialogElement;
dialog.showModal();
}
// 显示消息编号
const onShowClientMsgNo = (clientMsgNo: any) => {
content.value = clientMsgNo
const dialog = document.getElementById('content') as HTMLDialogElement;
dialog.showModal();
}
// 消息轨迹惦记
const onMessageTrace = (clientMsgNo: string) => {
if(!App.shard().systemSetting.messageTraceOn) {
alert("消息追踪功能未开启,请查看官网文档: https://githubim.com")
return
}
router.push(`/monitor/trace?clientMsgNo=${clientMsgNo}`)
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<!-- 发送者 -->
<div class="text-sm ml-10">
<label>发送者</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onFromUidSearch" />
</div>
<!-- 接受频道类型 -->
<div class="text-sm ml-10">
<label>接受频道</label>
<select class="select select-bordered max-w-xs select-sm w-20 ml-2"
v-on:change="onChannelTypeSearch" v-model="channelType">
<option value="1">个人</option>
<option value="2">群聊</option>
<option value="3">客服</option>
<option value="4">社区</option>
<option value="5">话题</option>
<option value="6">资讯</option>
<option value="7">数据</option>
</select>
<input type="text" placeholder="输入" v-model="channelId" class="input input-bordered select-sm ml-2"
v-on:change="onChannelIdSearch" />
</div>
<!-- 消息id -->
<div class="text-sm ml-10">
<label>消息ID</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onMessageIdSearch" />
</div>
<!-- 节点 -->
<!-- <div class="text-sm ml-3">
<label>节点</label>
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onNodeChange">
<option value="0">所有</option>
<option v-for="node in nodeTotal.data" :selected="node.id == selectedNodeId">{{ node.id }}
</option>
</select>
</div> -->
<!-- 消息内容 -->
<div class="text-sm ml-10">
<label>消息内容</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onPayloadSearch" />
</div>
<!-- 客户端唯一编号 -->
<div class="text-sm ml-10">
<label>客户端唯一编号</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onClientMsgNoSearch" />
</div>
</div>
<table class="table mt-10 table-pin-rows">
<!-- head -->
<thead>
<tr>
<th>
<div class="flex items-center">
消息ID
</div>
</th>
<th>
<div class="flex items-center">
消息序号
</div>
</th>
<th>
<div class="flex items-center">
发送者
</div>
</th>
<th>
<div class="flex items-center">
接受频道
</div>
</th>
<th>
<div class="flex items-center">
接受频道类型
</div>
</th>
<th>
<div class="flex items-center">
消息内容
</div>
</th>
<th>
<div class="flex items-center">
发送时间
</div>
</th>
<th>
<div class="flex items-center">
客户端唯一编号
</div>
</th>
<th>
<div class="flex items-center">
操作
</div>
</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="message in messages">
<td>{{ message.message_id }}</td>
<td>{{ message.message_seq }}</td>
<td>{{ message.from_uid }}</td>
<td>{{ message.channel_id }}</td>
<td>{{ message.channel_type }}</td>
<td class="text-blue-800" v-on:click="() => onShowMessageContent(message)"><a href="#">{{
ellipsis(base64Decode(message.payload), 40) }}</a></td>
<td>{{ message.timestamp_format }}</td>
<td class="text-blue-800" v-on:click="() => onShowClientMsgNo(message.client_msg_no)"><a
href="#">{{ ellipsis(message.client_msg_no, 20) }}</a></td>
<td class="flex">
<button class="btn btn-link btn-sm" v-on:click="()=>onMessageTrace(message.client_msg_no)">消息轨迹</button>
</td>
</tr>
</tbody>
</table>
<!-- <div class="flex flex-col gap-4 w-full mt-2" v-if="loading">
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
<div class="skeleton h-6 w-full"></div>
</div> -->
</div>
<div class="flex justify-end mt-10 mr-10">
<div className="join">
<button :class="{ 'join-item btn': true }" v-on:click="prevPage">«</button>
<button className="join-item btn">{{ currentPage }}</button>
<button :class="{ 'join-item btn': true }" v-on:click="nextPage">»</button>
</div>
</div>
<dialog id="content" class="modal">
<div class="modal-box flex flex-wrap gap-2">
<div>{{ content }}</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
<template #action="{ row }">
<el-space>
<el-button type="primary" link @click="onPageTrace(row)">消息轨迹</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 消息
</route>
</template>

View File

@@ -1,247 +1,234 @@
<script setup lang="ts">
// API 接口
import { dataApi } from '@/api/modules/data-api';
// 组件
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import Allowlist from './channel/components/Allowlist.vue';
import Denylist from './channel/components/Denylist.vue';
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { useRouter } from 'vue-router';
const router = useRouter();
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
uid: '',
offset_created_at: null,
limit: 20,
pre: 0
},
items: [
{
field: 'uid',
title: '用户UID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入用户UID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const userTotal = ref<any>({}); // 用户列表
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有上一页
const hasNext = ref<boolean>(true); // 是否有下一页
const pageSize = ref(20); // 每页显示数量
const offsetCreatedAt = ref(0); // 偏移量
const pre = ref(false); // 上一页
const uid = ref<string>(''); // 用户UID
const currentUids = ref<string[]>() // 当前用户列表
const loadList = async (query: any) => {
const res = await dataApi.users({ ...query });
if (res.data) {
hasNext.value = res.more !== 1;
hasPrev.value = currentPage.value <= 1;
return res.data;
}
return [];
};
const hasNext = ref<boolean>(true) // 是否有下一页
const hasPrev = ref<boolean>(false) // 是否有上一页
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'uid', title: '用户UID', minWidth: 220 },
{ field: 'device_count', title: '设备数', minWidth: 120 },
{ field: 'conn_count', title: '连接数', minWidth: 120 },
{ field: 'send_msg_count', title: '发出消息数', minWidth: 120 },
{ field: 'recv_msg_count', title: '接受消息数', minWidth: 120 },
{ field: 'send_msg_bytes', title: '发出消息大小', minWidth: 120 },
{ field: 'recv_msg_bytes', title: '接受消息大小', minWidth: 140 },
{ field: 'created_at_format', title: '创建时间', minWidth: 140 },
{ field: 'updated_at_format', title: '更新时间', minWidth: 140 },
{ field: 'action', title: '操作', width: 240, fixed: 'right', slots: { default: 'action' } }
]
onMounted(() => {
searchUser()
});
/**
* 分页切换
*/
const onPage = (type: 0 | 1) => {
// 下一页
let offset_created_at = 0;
const tableData = tableRef.value?.getData();
if (type === 0) {
currentPage.value = currentPage.value + 1;
if (tableData && tableData.length > 0) {
offset_created_at = tableData[tableData.length - 1].created_at;
const searchUser = () => {
API.shared.users({
uid: uid.value || "",
offsetCreatedAt: offsetCreatedAt.value,
pre: pre.value,
limit: pageSize.value,
}).then((res) => {
userTotal.value = res
hasNext.value = userTotal.value?.more === 1
hasPrev.value = currentPage.value > 1
if(pre.value) { // 如果是向上翻页,则有下页数据
hasNext.value = true
}
}).catch((err) => {
alert(err)
})
}
// 通过uid搜索
const onUidSearch = (e: any) => {
resetFilter()
uid.value = e.target.value
searchUser()
}
// 显示白名单
const onShowAllowlist = (uid:string) => {
getAllowlist(uid).then(() => {
const dialog = document.getElementById('userlist') as HTMLDialogElement;
dialog.showModal();
})
}
// 获取白名单列表
const getAllowlist = (uid: string) => {
return API.shared.allowlist(uid, 1).then((res) => {
currentUids.value = res
}).catch((err) => {
alert(err)
})
}
// 显示黑名单
const onShowDenylist = (uid:string) => {
getDenylist(uid).then(() => {
const dialog = document.getElementById('userlist') as HTMLDialogElement;
dialog.showModal();
})
}
const getDenylist = (uid: string) => {
return API.shared.denylist(uid, 1).then((res) => {
currentUids.value = res
}).catch((err) => {
alert(err)
})
}
const resetFilter = () => {
currentPage.value = 1
pre.value = false
offsetCreatedAt.value = 0
}
// 上一页
const prevPage = () => {
if (currentPage.value <= 1) {
hasPrev.value = false
return
}
}
// 上一页
if (type === 1 && currentPage.value > 1) {
currentPage.value = currentPage.value - 1;
if (tableData && tableData.length > 0) {
offset_created_at = tableData[0].created_at;
hasPrev.value = true
currentPage.value -= 1
pre.value = true
if (userTotal.value?.data?.length > 0) {
offsetCreatedAt.value = userTotal.value.data[0].created_at
}
}
searchUser()
}
formOptions.data = {
...formOptions.data,
offset_created_at,
pre: type
};
tableRef.value?.commitProxy('query');
};
/**
* 白名单
*/
const modelAllowlist = ref(false);
const channelIdAllowlist = ref('');
const channelTypeAllowlist = ref(0);
const onAllowlist = (row: any) => {
channelIdAllowlist.value = row.uid;
channelTypeAllowlist.value = 1;
modelAllowlist.value = true;
};
/**
* 黑名单
*/
const modelDenylist = ref(false);
const channelIdDenylist = ref('');
const channelTypeDenylist = ref(0);
const onDenylist = (row: any) => {
channelIdDenylist.value = row.uid;
channelTypeDenylist.value = 1;
modelDenylist.value = true;
};
/**
* 设备
* @param row
*/
const onDevice = (row: any) => {
router.push({
path: '/data/device',
query: {
uid: row.uid
// 下一页
const nextPage = () => {
if (userTotal.value?.more === 0 && !pre.value) {
return
}
});
};
/**
* 最近会话
* @param row
*/
const onRecentSession = (row: any) => {
router.push({
path: '/data/conversation',
query: {
uid: row.uid
currentPage.value += 1
pre.value = false
if (userTotal.value?.data?.length > 0) {
offsetCreatedAt.value = userTotal.value.data[userTotal.value.data.length - 1].created_at
}
});
};
searchUser()
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
<div>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<div class="text-sm ml-4">
<label>用户UID</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2"
v-on:change="onUidSearch" v-model="uid" />
</div>
</div>
<table class="table mt-5 table-pin-rows">
<thead>
<tr>
<th>
<div class="flex items-center">
用户UID
</div>
</th>
<!-- <th>
<div class="flex items-center">
设备数
</div>
</th>
<th>
<div class="flex items-center">
连接数
</div>
</th>
<th>
<div class="flex items-center">
发出消息数
</div>
</th>
<th>
<div class="flex items-center">
接受消息数
</div>
</th>
<th>
<div class="flex items-center">
发出消息大小
</div>
</th>
<th>
<div class="flex items-center">
接受消息大小
</div>
</th> -->
<th>
<div class="flex items-center">
创建时间
</div>
</th>
<th>
<div class="flex items-center">
更新时间
</div>
</th>
<th>
<div class="flex items-center">
操作
</div>
</th>
</tr>
</thead>
<tbody>
<!-- row 1 -->
<tr v-for="user in userTotal.data">
<td>
{{ user.uid }}
</td>
<!-- <td>{{ user.device_count }}</td>
<td>{{ user.conn_count }}</td>
<td>{{ user.send_msg_count }}</td>
<td>{{ user.recv_msg_count }}</td>
<td>{{ user.send_msg_bytes }}</td>
<td>{{ user.recv_msg_bytes }}</td> -->
<td>{{ user.created_at_format }}</td>
<td>{{ user.updated_at_format }}</td>
<td class="flex">
<button class="btn btn-link btn-sm"
@click="()=>onShowAllowlist(user.uid)">白名单</button>
<button class="btn btn-link btn-sm"
@click="()=>onShowDenylist(user.uid)">黑名单</button>
<button class="btn btn-link btn-sm"
@click="$router.push(`device?uid=${user.uid}`)">设备</button>
<button class="btn btn-link btn-sm"
@click="$router.push(`conversation?uid=${user.uid}`)">最近会话</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end mt-10 mr-10">
<div className="join">
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasPrev }" v-on:click="prevPage">«</button>
<button className="join-item btn">{{ currentPage }}</button>
<button :class="{ 'join-item btn': true, 'btn-disabled': !hasNext }" v-on:click="nextPage">»</button>
</div>
</div>
<dialog id="userlist" class="modal">
<div class="modal-box flex flex-wrap gap-2">
<div v-if="currentUids?.length == 0">无数据</div>
<a class="link" v-for="uid in currentUids">{{ uid }}</a>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
<template #action="{ row }">
<el-space>
<el-button type="primary" link @click="onAllowlist(row)">白名单</el-button>
<el-button type="primary" link @click="onDenylist(row)">黑名单</el-button>
<el-button type="primary" link @click="onDevice(row)">设备</el-button>
<el-button type="primary" link @click="onRecentSession(row)">最近会话</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
<!-- 白名单 -->
<Allowlist v-model="modelAllowlist" :channel-id="channelIdAllowlist" :channel-type="channelTypeAllowlist" />
<!-- 黑名单 -->
<Denylist v-model="modelDenylist" :channel-id="channelIdDenylist" :channel-type="channelTypeDenylist" />
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 用户
</route>
</template>

View File

@@ -1,102 +0,0 @@
<script name="Allowlist" lang="ts" setup>
// API 接口
import { dataApi } from '@/api/modules/data-api.ts';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
interface IProps {
channelId: string;
channelType: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
tableRef.value?.commitProxy('query');
}
}
}
);
/**
* 表格
*/
interface ITableItem {
uid: string;
}
const tableRef = ref<VxeGridInstance<ITableItem>>();
const apiLoadList = async () => {
const res = await dataApi.allowlist(props.channelId, props.channelType);
const getData: ITableItem[] = [];
if (res.length > 0) {
res.map((item: string) => {
getData.push({
uid: item
});
});
}
return getData;
};
const gridOptions = reactive<VxeGridProps<ITableItem>>({
border: true,
showOverflow: true,
height: 'auto',
stripe: true,
rowConfig: { isCurrent: true, isHover: true },
scrollY: { enabled: true, gt: 0 },
proxyConfig: {
ajax: {
query: () => {
return apiLoadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 64 },
{ field: 'uid', title: '白名单ID', minWidth: 160 }
]
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="白名单"
:width="640"
:height="460"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表格 -->
<div class="flex-1 card pt-0px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</vxe-modal>
</template>
<style lang="scss" scoped>
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,102 +0,0 @@
<script name="Denylist" lang="ts" setup>
// API 接口
import { dataApi } from '@/api/modules/data-api.ts';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
interface IProps {
channelId: string;
channelType: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
tableRef.value?.commitProxy('query');
}
}
}
);
/**
* 表格
*/
interface ITableItem {
uid: string;
}
const tableRef = ref<VxeGridInstance<ITableItem>>();
const apiLoadList = async () => {
const res = await dataApi.denylist(props.channelId, props.channelType);
const getData: ITableItem[] = [];
if (res.length > 0) {
res.map((item: string) => {
getData.push({
uid: item
});
});
}
return getData;
};
const gridOptions = reactive<VxeGridProps<ITableItem>>({
border: true,
showOverflow: true,
height: 'auto',
stripe: true,
rowConfig: { isCurrent: true, isHover: true },
scrollY: { enabled: true, gt: 0 },
proxyConfig: {
ajax: {
query: () => {
return apiLoadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 64 },
{ field: 'uid', title: '黑名单ID', minWidth: 160 }
]
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="黑名单"
:width="640"
:height="460"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表格 -->
<div class="flex-1 card pt-0px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</vxe-modal>
</template>
<style lang="scss" scoped>
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,102 +0,0 @@
<script name="Subscribers" lang="ts" setup>
// API 接口
import { dataApi } from '@/api/modules/data-api.ts';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
interface IProps {
channelId: string;
channelType: number;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
channelId: '',
channelType: 0
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.channelId) {
tableRef.value?.commitProxy('query');
}
}
}
);
/**
* 表格
*/
interface ITableItem {
uid: string;
}
const tableRef = ref<VxeGridInstance<ITableItem>>();
const apiLoadList = async () => {
const res = await dataApi.subscribers(props.channelId, props.channelType);
const getData: ITableItem[] = [];
if (res.length > 0) {
res.map((item: string) => {
getData.push({
uid: item
});
});
}
return getData;
};
const gridOptions = reactive<VxeGridProps<ITableItem>>({
border: true,
showOverflow: true,
height: 'auto',
stripe: true,
rowConfig: { isCurrent: true, isHover: true },
scrollY: { enabled: true, gt: 0 },
proxyConfig: {
ajax: {
query: () => {
return apiLoadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 64 },
{ field: 'uid', title: '订阅者ID', minWidth: 160 }
]
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="订阅者"
:width="640"
:height="460"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<!-- S 表格 -->
<div class="flex-1 card pt-0px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</wk-page>
</vxe-modal>
</template>
<style lang="scss" scoped>
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,247 +0,0 @@
<script setup lang="ts">
// API 接口
import { dataApi } from '@/api/modules/data-api.ts';
// 组件
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import Subscribers from './components/Subscribers.vue';
import Allowlist from './components/Allowlist.vue';
import Denylist from './components/Denylist.vue';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
import { CHANNEL_TYPE } from '@/constants';
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
channel_type: null,
channel_id: null,
offset_created_at: null,
limit: 20,
pre: 0
},
items: [
{
field: 'channel_type',
title: '频道类型',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择频道类型',
style: { width: '180px' }
},
options: CHANNEL_TYPE
}
},
{
field: 'channel_id',
title: '频道ID',
itemRender: { name: 'ElInput', props: { placeholder: '请输入频道ID' } }
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
tableRef.value?.commitProxy('query');
},
/** 重置 **/
reset() {
tableRef.value?.commitProxy('query');
}
};
/**
* 表格
**/
const tableRef = ref<VxeGridInstance<any>>();
const currentPage = ref(1); // 当前页
const hasPrev = ref<boolean>(false); // 是否有上一页
const hasNext = ref<boolean>(true); // 是否有下一页
const loadList = async (query: any) => {
const res = await dataApi.searchChannels({ ...query });
if (res.data) {
hasNext.value = res.more !== 1;
hasPrev.value = currentPage.value <= 1;
return res.data;
}
return [];
};
const gridOptions = reactive<VxeGridProps<any>>({
showOverflow: true,
height: 'auto',
border: true,
stripe: true,
rowConfig: {
isCurrent: true,
isHover: true
},
scrollY: {
enabled: true,
gt: 0
},
toolbarConfig: {
slots: {
buttons: 'tools'
},
refresh: {
icon: 'vxe-icon-refresh',
iconLoading: 'vxe-icon-refresh roll'
},
zoom: {
iconIn: 'vxe-icon-fullscreen',
iconOut: 'vxe-icon-minimize'
},
custom: true
},
proxyConfig: {
ajax: {
query: () => {
return loadList(formOptions.data);
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 54 },
{ field: 'channel_id', title: '频道ID', minWidth: 220 },
{
field: 'channel_type',
title: '频道类型',
minWidth: 120,
formatter({ cellValue }) {
const item = CHANNEL_TYPE.find(item => item.value === cellValue);
return item ? item.label : cellValue;
}
},
{ field: 'subscriber_count', title: '订阅者数量', minWidth: 120 },
{ field: 'allowlist_count', title: '黑名单数量', minWidth: 120 },
{ field: 'status_format', title: '白名单数量', minWidth: 120 },
{ field: 'last_msg_seq', title: '最大序号', minWidth: 120 },
{ field: 'last_msg_time_format', title: '最后消息时间', minWidth: 140 },
{ field: 'slot', title: '槽位', minWidth: 120 },
{ field: 'created_at_format', title: '创建时间', minWidth: 140 },
{ field: 'updated_at_format', title: '更新时间', minWidth: 140 },
{ field: 'action', title: '操作', width: 184, fixed: 'right', slots: { default: 'action' } }
]
});
/**
* 分页切换
*/
const onPage = (type: 0 | 1) => {
// 下一页
if (type === 0) {
currentPage.value = currentPage.value + 1;
}
// 上一页
if (type === 1 && currentPage.value > 1) {
currentPage.value = currentPage.value - 1;
}
console.log(currentPage.value);
formOptions.data = {
...formOptions.data,
pre: type
};
tableRef.value?.commitProxy('query');
};
/**
* 订阅者
*/
const modelSubscribers = ref(false);
const channelIdSubscribers = ref('');
const channelTypeSubscribers = ref(0);
const onSubscribers = (row: any) => {
channelIdSubscribers.value = row.channel_id;
channelTypeSubscribers.value = row.channel_type;
modelSubscribers.value = true;
};
/**
* 白名单
*/
const modelAllowlist = ref(false);
const channelIdAllowlist = ref('');
const channelTypeAllowlist = ref(0);
const onAllowlist = (row: any) => {
channelIdAllowlist.value = row.channel_id;
channelTypeAllowlist.value = row.channel_type;
modelAllowlist.value = true;
};
/**
* 黑名单
*/
const modelDenylist = ref(false);
const channelIdDenylist = ref('');
const channelTypeDenylist = ref(0);
const onDenylist = (row: any) => {
channelIdDenylist.value = row.channel_id;
channelTypeDenylist.value = row.channel_type;
modelDenylist.value = true;
};
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<!-- S 表格 -->
<div class="flex-1 card !pt-4px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions">
<template #tools>
<el-space>
<el-button type="primary" :disabled="hasPrev" @click="onPage(1)">上一页</el-button>
<el-button type="primary" :disabled="hasNext" @click="onPage(0)">下一页</el-button>
</el-space>
</template>
<template #action="{ row }">
<el-space>
<el-button type="primary" link @click="onSubscribers(row)">订阅者</el-button>
<el-button type="primary" link @click="onAllowlist(row)">白名单</el-button>
<el-button type="primary" link @click="onDenylist(row)">黑名单</el-button>
</el-space>
</template>
</vxe-grid>
</div>
<!-- E 表格 -->
<!-- 订阅者 -->
<Subscribers v-model="modelSubscribers" :channel-id="channelIdSubscribers" :channel-type="channelTypeSubscribers" />
<!-- 白名单 -->
<Allowlist v-model="modelAllowlist" :channel-id="channelIdAllowlist" :channel-type="channelTypeAllowlist" />
<!-- 黑名单 -->
<Denylist v-model="modelDenylist" :channel-id="channelIdDenylist" :channel-type="channelTypeDenylist" />
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 频道
</route>

View File

@@ -1,10 +0,0 @@
<script setup lang="ts"></script>
<template>
<div>首页</div>
</template>
<route lang="yaml">
meta:
title: 首页
</route>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import App from '../../services/App.ts';
import API from '../../services/API';
import { ref } from 'vue';
const username = ref<string>('guest')
const password = ref<string>('guest')
const onLogin = () => {
console.log('login')
if(!username.value|| username.value.trim()=='') {
alert('请输入用户名')
return
}
if(!password.value || password.value.trim()=='') {
alert('请输入密码')
return
}
API.shared.login(username.value, password.value).then((res) => {
console.log(res)
const loginInfo = App.shard().loginInfo
loginInfo.username = res.username
loginInfo.token = res.token
loginInfo.setPermissionFromStr(res.permissions)
loginInfo.save()
window.location.href = '/web'
}).catch((err) => {
console.log(err)
alert(err.msg)
})
}
</script>
<template>
<div class="flex flex-col items-center justify-center h-full mb-20">
<div>
<img src="/logo_big.png" class="h-[80px] w-[80px]" />
</div>
<div class="flex flex-col items-center justify-center pt-10">
<h1 class="text-[1.5rem] font-semibold">WuKongIM分布式管理系统</h1>
<div class="mt-4 text-gray-500">演示账号: guest 密码guest</div>
<div class="mt-10">
<input type="text" class="input border-gray-200 w-[20rem]" placeholder="用户名" v-model="username" >
</div>
<div class="mt-5">
<input type="password" class="input border-gray-200 w-[20rem]" placeholder="密码" v-model="password" />
</div>
<div class="mt-5">
<button class="btn btn-primary w-[10rem] mt-10" v-on:click="() => onLogin()">登录</button>
</div>
</div>
</div>
</template>

View File

@@ -1,123 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router';
import type { FormInstance, FormRules } from 'element-plus';
import { useUserStore } from '@/stores/modules/user';
import { WK_CONFIG, HOME_URL } from '@/config';
// API接口
import { loginApi } from '@/api/modules/login-api';
const APP_TITLE = WK_CONFIG.APP_TITLE_LOGIN;
const router = useRouter();
const userStore = useUserStore();
interface RuleForm {
username: string;
password: string;
}
// 登录表单
const formRef = ref<FormInstance>();
const loginForm = reactive<RuleForm>({
username: '',
password: ''
});
const rules = reactive<FormRules<RuleForm>>({
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
});
const loginButLoading = ref(false);
// 登录
const onLoginClick = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
loginButLoading.value = true;
await formEl.validate(async valid => {
loginButLoading.value = false;
if (valid) {
const res = await loginApi.login(loginForm) as any;
userStore.setUserInfo(res);
userStore.setToken(res.token);
userStore.setPermissions(res.permissions);
await router.push(HOME_URL);
}
});
};
</script>
<template>
<div class="wh-full lex flex-1 flex-col justify-center items-center overflow-hidden">
<div class="flex-center wh-full relative">
<div class="login-bg"></div>
<div class="flex flex-col items-center justify-center relative">
<el-card class="w-460px !border-none" body-style="padding: 48px;">
<div class="flex justify-center mb-12px">
<img class="h-64px w-64px" src="/logo.png" alt="logo" />
</div>
<div class="flex justify-center mb-18px">
<h3 class="text-24px app-text-fg-high mt-0 mb-24px" style="align-self: flex-start; font-weight: 500">
{{ APP_TITLE }}
</h3>
</div>
<el-form ref="formRef" :model="loginForm" :rules="rules" size="large" autocomplete="on">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="请输入账号">
<template #prefix>
<el-icon class="el-input__icon">
<i-wk-people theme="outline" size="24" />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
@keyup.enter="onLoginClick(formRef)"
>
<template #prefix>
<el-icon class="el-input__icon">
<i-wk-lock theme="outline" size="24" />
</el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<div class="flex justify-between">
<div class="flex-initial">演示账号guest 密码guest</div>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loginButLoading" class="w-full" @click="onLoginClick(formRef)">
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-bg {
width: 100%;
height: 100%;
position: absolute;
background: linear-gradient(154deg, #07070915 30%, hsl(var(--primary) / 30%) 48%, #07070915 64%);
filter: blur(100px);
}
</style>
<route lang="yaml">
meta:
title: 登录
layout: false
</route>

View File

@@ -1,110 +1,9 @@
<script setup lang="ts">
// API 接口
import { clusterApi } from '@/api/modules/cluster-api';
import { monitorApi } from '@/api/modules/monitor-api';
// 常量
import {APP_DOC, LATEST_TIME} from '@/constants';
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import { useUserStore } from '@/stores/modules/user';
const userStore = useUserStore();
/**
* 查询条件
* */
const nodeList = ref<any[]>([]);
const formOptions = reactive<VxeFormProps<any>>({
data: {
node_id: 0,
latest: 300
},
items: [
{
field: 'node_id',
title: '节点',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: nodeList
}
},
{
field: 'latest',
title: '时间',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: LATEST_TIME
}
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
loadMetrics();
},
/** 重置 **/
reset() {
formOptions.data = {
node_id: 0,
latest: 300
};
loadMetrics();
}
};
const getNodes = async () => {
const nodes: any[] = [
{
value: 0,
label: '所有'
}
];
const res = await clusterApi.simpleNodes();
if (res.data.length > 0) {
res.data.map((item: any) => {
nodes.push({
value: item.id,
label: item.id
});
});
}
nodeList.value = nodes;
if (nodes.length > 0) {
formOptions.data = {
...formOptions.data,
node_id: nodes[0].value
};
}
};
interface Series {
name?: string;
type?: string;
data: Point[];
}
interface Point {
timestamp?: number;
value?: number;
}
import MonitorPanel from '../../components/MonitorPanel.vue'
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { Series } from '../../services/Model';
import App from '../../services/App';
const connectionsRef = ref<Series[]>([]); // 连接数
const onlineUserCountRef = ref<Series[]>([]); // 在线用户数
@@ -118,232 +17,274 @@ const recvPacketBytesRateRef = ref<Series[]>([]); // 接收数据包字节数
const connPacketCountRateRef = ref<Series[]>([]); // 连接数据包数
const connPacketBytesRateRef = ref<Series[]>([]); // 连接数据包字节数
const pingPacketCountRateRef = ref<Series[]>([]); // ping数据包数
const pingPacketBytesRateRef = ref<Series[]>([]); // ping数据包字节数
const loadMetrics = () => {
monitorApi.apppMetrics(formOptions.data).then(res => {
const connections = [];
const onlineUserCount = [];
const onlineDeviceCount = [];
const nodeTotal = ref<any>({}); // 节点列表
const selectedNodeId = ref<number>(0) // 选中的节点ID
const latest = ref<number>(60 * 5) // 最近时间
const sendPacketCountRate = [];
const sendPacketBytesRate = [];
const sendackPacketCountRate = [];
const sendackPacketBytesRate = [];
const recvPacketCountRate = [];
const recvPacketBytesRate = [];
const recvackPacketCountRate = [];
const recvackPacketBytesRate = [];
const connPacketCountRate = [];
const connPacketBytesRate = [];
const connackPacketCountRate = [];
const connackPacketBytesRate = [];
const pingPacketCountRate = [];
const pingPacketBytesRate = [];
const pongPacketCountRate = [];
const pongPacketBytesRate = [];
for (let index = 0; index < res.length; index++) {
const d = res[index];
connections.push({ timestamp: d.timestamp, value: d.conn_count });
onlineUserCount.push({ timestamp: d.timestamp, value: d.online_user_count });
onlineDeviceCount.push({ timestamp: d.timestamp, value: d.online_device_count });
sendPacketCountRate.push({ timestamp: d.timestamp, value: d.send_packet_count_rate });
sendPacketBytesRate.push({ timestamp: d.timestamp, value: d.send_packet_bytes_rate });
sendackPacketCountRate.push({ timestamp: d.timestamp, value: d.sendack_packet_count_rate });
sendackPacketBytesRate.push({ timestamp: d.timestamp, value: d.sendack_packet_bytes_rate });
recvPacketCountRate.push({ timestamp: d.timestamp, value: d.recv_packet_count_rate });
recvPacketBytesRate.push({ timestamp: d.timestamp, value: d.recv_packet_bytes_rate });
recvackPacketCountRate.push({ timestamp: d.timestamp, value: d.recvack_packet_count_rate });
recvackPacketBytesRate.push({ timestamp: d.timestamp, value: d.recvack_packet_bytes_rate });
connPacketCountRate.push({ timestamp: d.timestamp, value: d.conn_packet_count_rate });
connPacketBytesRate.push({ timestamp: d.timestamp, value: d.conn_packet_bytes_rate });
connackPacketCountRate.push({ timestamp: d.timestamp, value: d.connack_packet_count_rate });
connackPacketBytesRate.push({ timestamp: d.timestamp, value: d.connack_packet_bytes_rate });
pingPacketCountRate.push({ timestamp: d.timestamp, value: d.ping_packet_count_rate });
pingPacketBytesRate.push({ timestamp: d.timestamp, value: d.ping_packet_bytes_rate });
pongPacketCountRate.push({ timestamp: d.timestamp, value: d.pong_packet_count_rate });
pongPacketBytesRate.push({ timestamp: d.timestamp, value: d.pong_packet_bytes_rate });
}
connectionsRef.value = [
{
name: '连接数',
data: connections
}
];
pingPacketCountRateRef.value = [
{
name: 'ping',
data: pingPacketCountRate
},
{
name: 'pong',
data: pongPacketCountRate
}
];
pingPacketBytesRateRef.value = [
{
name: 'ping',
data: pingPacketBytesRate
},
{
name: 'pong',
data: pongPacketBytesRate
}
];
onlineUserCountRef.value = [
{
name: '在线用户',
data: onlineUserCount
}
];
sendPacketCountRateRef.value = [
{
name: '发送包',
data: sendPacketCountRate
},
{
name: '发送应答包',
data: sendackPacketCountRate
}
];
sendPacketBytesRateRef.value = [
{
name: '发送包',
data: sendPacketBytesRate
},
{
name: '发送应答包',
data: sendackPacketBytesRate
}
];
recvPacketCountRateRef.value = [
{
name: '接受包',
data: recvPacketCountRate
},
{
name: '接受应答包',
data: recvackPacketCountRate
}
];
recvPacketBytesRateRef.value = [
{
name: '接受包',
data: recvPacketBytesRate
},
{
name: '接受应答包',
data: recvackPacketBytesRate
}
];
connPacketCountRateRef.value = [
{
name: '连接包',
data: connPacketCountRate
},
{
name: '连接应答包',
data: connackPacketCountRate
}
];
connPacketBytesRateRef.value = [
{
name: '连接包',
data: connPacketBytesRate
},
{
name: '连接应答包',
data: connackPacketBytesRate
}
];
});
};
const isShow = computed(() => {
return userStore.systemSetting.prometheusOn;
});
onMounted(() => {
// 是否开启prometheus
if (!userStore.systemSetting.prometheusOn) {
return;
}
getNodes();
loadMetrics();
});
if(!App.shard().systemSetting.prometheusOn) {
return
}
loadMetrics()
API.shared.simpleNodes().then((res) => {
nodeTotal.value = res
}).catch((err) => {
alert(err)
})
})
const loadMetrics = () => {
API.shared.apppMetrics(selectedNodeId.value,latest.value).then((res) => {
var connections = []
var onlineUserCount = []
var onlineDeviceCount = []
var sendPacketCountRate = []
var sendPacketBytesRate = []
var sendackPacketCountRate = []
var sendackPacketBytesRate = []
var recvPacketCountRate = []
var recvPacketBytesRate = []
var recvackPacketCountRate = []
var recvackPacketBytesRate = []
var connPacketCountRate = []
var connPacketBytesRate = []
var connackPacketCountRate = []
var connackPacketBytesRate = []
var pingPacketCountRate = []
var pingPacketBytesRate = []
var pongPacketCountRate = []
var pongPacketBytesRate = []
for (let index = 0; index < res.length; index++) {
const d = res[index];
connections.push({ timestamp: d.timestamp, value: d.conn_count })
onlineUserCount.push({ timestamp: d.timestamp, value: d.online_user_count })
onlineDeviceCount.push({ timestamp: d.timestamp, value: d.online_device_count })
sendPacketCountRate.push({ timestamp: d.timestamp, value: d.send_packet_count_rate })
sendPacketBytesRate.push({ timestamp: d.timestamp, value: d.send_packet_bytes_rate })
sendackPacketCountRate.push({ timestamp: d.timestamp, value: d.sendack_packet_count_rate })
sendackPacketBytesRate.push({ timestamp: d.timestamp, value: d.sendack_packet_bytes_rate })
recvPacketCountRate.push({ timestamp: d.timestamp, value: d.recv_packet_count_rate })
recvPacketBytesRate.push({ timestamp: d.timestamp, value: d.recv_packet_bytes_rate })
recvackPacketCountRate.push({ timestamp: d.timestamp, value: d.recvack_packet_count_rate })
recvackPacketBytesRate.push({ timestamp: d.timestamp, value: d.recvack_packet_bytes_rate })
connPacketCountRate.push({ timestamp: d.timestamp, value: d.conn_packet_count_rate })
connPacketBytesRate.push({ timestamp: d.timestamp, value: d.conn_packet_bytes_rate })
connackPacketCountRate.push({ timestamp: d.timestamp, value: d.connack_packet_count_rate })
connackPacketBytesRate.push({ timestamp: d.timestamp, value: d.connack_packet_bytes_rate })
pingPacketCountRate.push({ timestamp: d.timestamp, value: d.ping_packet_count_rate })
pingPacketBytesRate.push({ timestamp: d.timestamp, value: d.ping_packet_bytes_rate })
pongPacketCountRate.push({ timestamp: d.timestamp, value: d.pong_packet_count_rate })
pongPacketBytesRate.push({ timestamp: d.timestamp, value: d.pong_packet_bytes_rate })
}
connectionsRef.value = [{
name: "连接数",
data: connections,
}]
pingPacketCountRateRef.value = [
{
name: "ping",
data: pingPacketCountRate,
},
{
name: "pong",
data: pongPacketCountRate,
},
]
pingPacketBytesRateRef.value = [
{
name: "ping",
data: pingPacketBytesRate,
},
{
name: "pong",
data: pongPacketBytesRate,
},
]
onlineUserCountRef.value = [
{
name: "在线用户",
data: onlineUserCount,
},
]
sendPacketCountRateRef.value = [
{
name: "发送包",
data: sendPacketCountRate,
},
{
name: "发送应答包",
data: sendackPacketCountRate
},
]
sendPacketBytesRateRef.value = [
{
name: "发送包",
data: sendPacketBytesRate,
},
{
name: "发送应答包",
data: sendackPacketBytesRate
},
]
recvPacketCountRateRef.value = [
{
name: "接受包",
data: recvPacketCountRate
},
{
name: "接受应答包",
data: recvackPacketCountRate,
},
]
recvPacketBytesRateRef.value = [
{
name: "接受包",
data: recvPacketBytesRate,
},
{
name: "接受应答包",
data: recvackPacketBytesRate
},
]
connPacketCountRateRef.value = [
{
name: "连接包",
data: connPacketCountRate
},
{
name: "连接应答包",
data: connackPacketCountRate
},
]
connPacketBytesRateRef.value = [
{
name: "连接包",
data: connPacketBytesRate,
},
{
name: "连接应答包",
data: connackPacketBytesRate
},
]
}).catch((err) => {
alert(err)
})
}
const onNodeChange = (e: any) => {
selectedNodeId.value = e.target.value
loadMetrics()
}
const onLatestChange = (e: any) => {
latest.value = e.target.value
loadMetrics()
}
const onRefresh = () => {
loadMetrics()
}
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<div class="overflow-auto h-5/6">
<div class="flex">
<div class="text-sm ml-3">
<label>节点</label>
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onNodeChange">
<option value="0" :selected="selectedNodeId==0">所有</option>
<option v-for="node in nodeTotal.data" :selected="node.id == selectedNodeId">{{ node.id }}</option>
</select>
</div>
<div v-if="isShow" class="flex-1 card overflow-hidden">
<el-scrollbar>
<div class="flex flex-wrap justify-left">
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="connectionsRef" title="长连接数" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="onlineUserCountRef" title="在线用户" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketCountRateRef" title="发送包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketBytesRateRef" title="发送包(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="recvPacketCountRateRef" title="接收包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="recvPacketBytesRateRef" title="接收包字节(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="connPacketCountRateRef" title="连接包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="connPacketBytesRateRef" title="连接包字节(字节" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="pingPacketCountRateRef" title="ping包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="pingPacketBytesRateRef" title="ping包字节(字节)" />
</div>
<div class="text-sm ml-10">
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onLatestChange">
<option selected :value="60*5">过去5分钟</option>
<option :value="60*30">过去30分钟</option>
<option :value="60*60">过去1小时</option>
<option :value="60*60*6">过去6小时</option>
<option :value="60*60*24">过去1天</option>
<option :value="60*60*24*3">过去3天</option>
<option :value="60*60*24*7">过去7天</option>
</select>
</div>
<button class="btn btn-sm ml-10" v-on:click="onRefresh">立马刷新</button>
</div>
<br />
<div class="flex flex-wrap justify-left" v-if="App.shard().systemSetting.prometheusOn">
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="connectionsRef" title="长连接数" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="onlineUserCountRef" title="在线用户" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketCountRateRef" title="发送包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketBytesRateRef" title="发送包(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="recvPacketCountRateRef" title="接收包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="recvPacketBytesRateRef" title="接收包字节(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="connPacketCountRateRef" title="连接包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="connPacketBytesRateRef" title="连接包字节(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="pingPacketCountRateRef" title="ping包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="pingPacketBytesRateRef" title="ping包字节(字节)" />
</div>
</div>
</div>
<div class="text text-center mt-10 text-red-500" v-else>
监控功能未开启请查看官网文档 https://githubim.com
</div>
</el-scrollbar>
</div>
<div v-else class="flex-1 card flex items-center justify-center overflow-hidden">
监控功能未开启请查看官网文档
<el-link type="primary" :href="APP_DOC" target="_blank">{{ APP_DOC }}</el-link>
</div>
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 应用
</route>
</template>

View File

@@ -1,67 +1,13 @@
<script setup lang="ts">
import { monitorApi } from '@/api/modules/monitor-api';
// 常量
import { APP_DOC, LATEST_TIME } from '@/constants';
import MonitorPanel from '../../components/MonitorPanel.vue'
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { Series,setSeries } from '../../services/Model';
import App from '../../services/App';
import { setSeries } from '@/utils';
const latest = ref<number>(60 * 5) // 最近时间
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import { useUserStore } from '@/stores/modules/user';
const userStore = useUserStore();
const formOptions = reactive<VxeFormProps<any>>({
data: {
latest: 300
},
items: [
{
field: 'latest',
title: '时间',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: LATEST_TIME
}
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
loadMetrics();
},
/** 重置 **/
reset() {
formOptions.data = {
latest: 300
};
loadMetrics();
}
};
interface Series {
name?: string;
type?: string;
data: Point[];
}
interface Point {
timestamp?: number;
value?: number;
}
const msgIncomingCountRateRef = ref<Series[]>([]);
const msgIncomingBytesRateRef = ref<Series[]>([]);
const msgOutgoingCountRateRef = ref<Series[]>([]);
@@ -82,196 +28,269 @@ const msgSyncIncomingBytesRateRef = ref<Series[]>([]);
const msgSyncOutgoingCountRateRef = ref<Series[]>([]);
const msgSyncOutgoingBytesRateRef = ref<Series[]>([]);
const channelActiveCountRef = ref<Series[]>([]);
const channelActiveCountRef = ref<Series[]>([])
const channelProposeCountRateRef = ref<Series[]>([]);
const channelProposeFailedCountRate = ref<Series[]>([]);
const channelProposeLatencyOver500msRate = ref<Series[]>([]);
const channelProposeLatencyUnder500msRate = ref<Series[]>([]);
const channelProposeCountRateRef = ref<Series[]>([])
const channelProposeFailedCountRate = ref<Series[]>([])
const channelProposeLatencyOver500msRate = ref<Series[]>([])
const channelProposeLatencyUnder500msRate = ref<Series[]>([])
const msgPingIncomingCountRateRef = ref<Series[]>([]);
const msgPingIncomingBytesRateRef = ref<Series[]>([]);
const msgPingOutgoingCountRateRef = ref<Series[]>([]);
const msgPingOutgoingBytesRateRef = ref<Series[]>([]);
const msgPingIncomingCountRateRef = ref<Series[]>([])
const msgPingIncomingBytesRateRef = ref<Series[]>([])
const msgPingOutgoingCountRateRef = ref<Series[]>([])
const msgPingOutgoingBytesRateRef = ref<Series[]>([])
const loadMetrics = () => {
monitorApi.clusterMetrics(formOptions.data).then(res => {
msgIncomingCountRateRef.value = [];
msgIncomingBytesRateRef.value = [];
msgOutgoingCountRateRef.value = [];
msgOutgoingBytesRateRef.value = [];
sendPacketIncomingCountRateRef.value = [];
sendPacketIncomingBytesRateRef.value = [];
sendPacketOutgoingBytesRateRef.value = [];
sendPacketOutgoingCountRateRef.value = [];
msgSyncIncomingCountRateRef.value = [];
msgSyncIncomingBytesRateRef.value = [];
msgSyncOutgoingCountRateRef.value = [];
msgSyncOutgoingBytesRateRef.value = [];
channelActiveCountRef.value = [];
channelProposeCountRateRef.value = [];
channelProposeFailedCountRate.value = [];
channelProposeLatencyOver500msRate.value = [];
channelProposeLatencyUnder500msRate.value = [];
msgPingIncomingCountRateRef.value = [];
msgPingIncomingBytesRateRef.value = [];
msgPingOutgoingCountRateRef.value = [];
msgPingOutgoingBytesRateRef.value = [];
for (let index = 0; index < res.length; index++) {
const d = res[index];
setSeries('msg_incoming_count_rate', d, msgIncomingCountRateRef.value);
setSeries('msg_incoming_bytes_rate', d, msgIncomingBytesRateRef.value);
setSeries('msg_outgoing_count_rate', d, msgOutgoingCountRateRef.value);
setSeries('msg_outgoing_bytes_rate', d, msgOutgoingBytesRateRef.value);
setSeries('channel_msg_incoming_count_rate', d, channelMsgIncomingCountRateRef.value);
setSeries('channel_msg_incoming_bytes_rate', d, channelMsgIncomingBytesRateRef.value);
setSeries('channel_msg_outgoing_count_rate', d, channelMsgOutgoingCountRateRef.value);
setSeries('channel_msg_outgoing_bytes_rate', d, channelMsgOutgoingBytesRateRef.value);
setSeries('sendpacket_incoming_count_rate', d, sendPacketIncomingCountRateRef.value);
setSeries('sendpacket_incoming_bytes_rate', d, sendPacketIncomingBytesRateRef.value);
setSeries('sendpacket_outgoing_bytes_rate', d, sendPacketOutgoingBytesRateRef.value);
setSeries('sendpacket_outgoing_count_rate', d, sendPacketOutgoingCountRateRef.value);
setSeries('msg_sync_incoming_bytes_rate', d, msgSyncIncomingBytesRateRef.value);
setSeries('msg_sync_incoming_count_rate', d, msgSyncIncomingCountRateRef.value);
setSeries('msg_sync_outgoing_bytes_rate', d, msgSyncOutgoingBytesRateRef.value);
setSeries('msg_sync_outgoing_count_rate', d, msgSyncOutgoingCountRateRef.value);
setSeries('channel_active_count', d, channelActiveCountRef.value);
setSeries('channel_propose_count_rate', d, channelProposeCountRateRef.value);
setSeries('channel_propose_failed_count_rate', d, channelProposeFailedCountRate.value);
setSeries('channel_propose_latency_over_500ms_rate', d, channelProposeLatencyOver500msRate.value);
setSeries('channel_propose_latency_under_500ms_rate', d, channelProposeLatencyUnder500msRate.value);
setSeries('msg_ping_incoming_count_rate', d, msgPingIncomingCountRateRef.value);
setSeries('msg_ping_incoming_bytes_rate', d, msgPingIncomingBytesRateRef.value);
setSeries('msg_ping_outgoing_count_rate', d, msgPingOutgoingCountRateRef.value);
setSeries('msg_ping_outgoing_bytes_rate', d, msgPingOutgoingBytesRateRef.value);
}
});
};
const isShow = computed(() => {
return userStore.systemSetting.prometheusOn;
});
onMounted(() => {
// 是否开启prometheus
if (!userStore.systemSetting.prometheusOn) {
return;
}
if(!App.shard().systemSetting.prometheusOn) {
return
}
loadMetrics()
})
const loadMetrics = () => {
API.shared.clusterMetrics(latest.value).then((res) => {
msgIncomingCountRateRef.value = []
msgIncomingBytesRateRef.value = []
msgOutgoingCountRateRef.value = []
msgOutgoingBytesRateRef.value = []
sendPacketIncomingCountRateRef.value = []
sendPacketIncomingBytesRateRef.value = []
sendPacketOutgoingBytesRateRef.value = []
sendPacketOutgoingCountRateRef.value = []
msgSyncIncomingCountRateRef.value = []
msgSyncIncomingBytesRateRef.value = []
msgSyncOutgoingCountRateRef.value = []
msgSyncOutgoingBytesRateRef.value = []
channelActiveCountRef.value = []
channelProposeCountRateRef.value = []
channelProposeFailedCountRate.value = []
channelProposeLatencyOver500msRate.value = []
channelProposeLatencyUnder500msRate.value = []
msgPingIncomingCountRateRef.value = []
msgPingIncomingBytesRateRef.value = []
msgPingOutgoingCountRateRef.value = []
msgPingOutgoingBytesRateRef.value = []
for (let index = 0; index < res.length; index++) {
const d = res[index];
setSeries("msg_incoming_count_rate", d, msgIncomingCountRateRef.value)
setSeries("msg_incoming_bytes_rate", d, msgIncomingBytesRateRef.value)
setSeries("msg_outgoing_count_rate", d, msgOutgoingCountRateRef.value)
setSeries("msg_outgoing_bytes_rate", d, msgOutgoingBytesRateRef.value)
setSeries("channel_msg_incoming_count_rate", d, channelMsgIncomingCountRateRef.value)
setSeries("channel_msg_incoming_bytes_rate", d, channelMsgIncomingBytesRateRef.value)
setSeries("channel_msg_outgoing_count_rate", d, channelMsgOutgoingCountRateRef.value)
setSeries("channel_msg_outgoing_bytes_rate", d, channelMsgOutgoingBytesRateRef.value)
setSeries("sendpacket_incoming_count_rate", d, sendPacketIncomingCountRateRef.value)
setSeries("sendpacket_incoming_bytes_rate", d, sendPacketIncomingBytesRateRef.value)
setSeries("sendpacket_outgoing_bytes_rate", d, sendPacketOutgoingBytesRateRef.value)
setSeries("sendpacket_outgoing_count_rate", d, sendPacketOutgoingCountRateRef.value)
setSeries("msg_sync_incoming_bytes_rate", d, msgSyncIncomingBytesRateRef.value)
setSeries("msg_sync_incoming_count_rate", d, msgSyncIncomingCountRateRef.value)
setSeries("msg_sync_outgoing_bytes_rate", d, msgSyncOutgoingBytesRateRef.value)
setSeries("msg_sync_outgoing_count_rate", d, msgSyncOutgoingCountRateRef.value)
setSeries("channel_active_count", d, channelActiveCountRef.value)
setSeries("channel_propose_count_rate", d, channelProposeCountRateRef.value)
setSeries("channel_propose_failed_count_rate", d, channelProposeFailedCountRate.value)
setSeries("channel_propose_latency_over_500ms_rate", d, channelProposeLatencyOver500msRate.value)
setSeries("channel_propose_latency_under_500ms_rate", d, channelProposeLatencyUnder500msRate.value)
setSeries("msg_ping_incoming_count_rate", d, msgPingIncomingCountRateRef.value)
setSeries("msg_ping_incoming_bytes_rate", d, msgPingIncomingBytesRateRef.value)
setSeries("msg_ping_outgoing_count_rate", d, msgPingOutgoingCountRateRef.value)
setSeries("msg_ping_outgoing_bytes_rate", d, msgPingOutgoingBytesRateRef.value)
}
})
}
const onLatestChange = (e: any) => {
latest.value = e.target.value
loadMetrics()
}
const onRefresh = () => {
loadMetrics()
}
loadMetrics();
});
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<div v-if="isShow" class="flex-1 card overflow-hidden">
<el-scrollbar>
<div class="flex flex-wrap justify-left">
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelProposeCountRateRef" title="频道提案(次)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelProposeLatencyUnder500msRate" title="频道提案小于500ms(次)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelProposeLatencyOver500msRate" title="频道提案大于500ms(次)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelProposeFailedCountRate" title="频道失败提案(次)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgIncomingCountRateRef" title="流入总消息(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgIncomingBytesRateRef" title="流入总消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgOutgoingCountRateRef" title="流出总消息(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgOutgoingBytesRateRef" title="流出总消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelMsgIncomingBytesRateRef" title="流入频道消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelMsgOutgoingCountRateRef" title="流出频道消息(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="channelMsgOutgoingBytesRateRef" title="流出频道消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgSyncIncomingCountRateRef" title="流入同步消息(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgSyncIncomingBytesRateRef" title="流入同步消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgSyncOutgoingCountRateRef" title="流出同步消息(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgOutgoingBytesRateRef" title="流出同步消息(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketIncomingCountRateRef" title="转入的发送包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketIncomingBytesRateRef" title="转入的发送包(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketOutgoingCountRateRef" title="转出的发送包(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="sendPacketOutgoingBytesRateRef" title="转出的发送包(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgPingIncomingCountRateRef" title="流入的Ping(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgPingIncomingBytesRateRef" title="流入的Ping(字节)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgPingOutgoingCountRateRef" title="流出的Ping(个)" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="msgPingOutgoingBytesRateRef" title="流出的Ping(字节)" />
</div>
<div class="overflow-auto h-5/6">
<div class="flex">
<div class="text-sm ml-10">
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onLatestChange">
<option selected :value="60 * 5">过去5分钟</option>
<option :value="60 * 30">过去30分钟</option>
<option :value="60 * 60">过去1小时</option>
<option :value="60 * 60 * 6">过去6小时</option>
<option :value="60 * 60 * 24">过去1天</option>
<option :value="60 * 60 * 24 * 3">过去3天</option>
<option :value="60 * 60 * 24 * 7">过去7天</option>
</select>
</div>
<button class="btn btn-sm ml-10" v-on:click="onRefresh">立马刷新</button>
</div>
</el-scrollbar>
</div>
<div v-else class="flex-1 card flex items-center justify-center overflow-hidden">
监控功能未开启请查看官网文档
<el-link type="primary" :href="APP_DOC" target="_blank">{{ APP_DOC }}</el-link>
</div>
</wk-page>
</template>
<br />
<div class="flex flex-wrap justify-left" v-if="App.shard().systemSetting.prometheusOn">
<style scoped lang="scss"></style>
<!-- propose -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelProposeCountRateRef" title="频道提案(次)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelProposeLatencyUnder500msRate" title="频道提案小于500ms(次)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelProposeLatencyOver500msRate" title="频道提案大于500ms(次)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelProposeFailedCountRate" title="频道失败提案(次)" />
</div>
</div>
<!-- message -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgIncomingCountRateRef" title="流入总消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgIncomingBytesRateRef" title="流入总消息(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgOutgoingCountRateRef" title="流出总消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgOutgoingBytesRateRef" title="流出总消息(字节)" />
</div>
</div>
<route lang="yaml">
meta:
title: 分布式
</route>
<!-- message -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelMsgIncomingCountRateRef" title="流入频道消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelMsgIncomingBytesRateRef" title="流入频道消息(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelMsgOutgoingCountRateRef" title="流出频道消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelMsgOutgoingBytesRateRef" title="流出频道消息(字节)" />
</div>
</div>
<!-- channel active -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="channelActiveCountRef" title="激活的频道(个)" />
</div>
</div>
<!-- sync -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgSyncIncomingCountRateRef" title="流入同步消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgSyncIncomingBytesRateRef" title="流入同步消息(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgSyncOutgoingCountRateRef" title="流出同步消息(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgOutgoingBytesRateRef" title="流出同步消息(字节)" />
</div>
</div>
<!-- sendpacket -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketIncomingCountRateRef" title="转入的发送包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketIncomingBytesRateRef" title="转入的发送包(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketOutgoingCountRateRef" title="转出的发送包(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="sendPacketOutgoingBytesRateRef" title="转出的发送包(字节)" />
</div>
</div>
<!-- ping -->
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgPingIncomingCountRateRef" title="流入的Ping(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgPingIncomingBytesRateRef" title="流入的Ping(字节)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgPingOutgoingCountRateRef" title="流出的Ping(个)" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="msgPingOutgoingBytesRateRef" title="流出的Ping(字节)" />
</div>
</div>
</div>
<div class="text text-center mt-10 text-red-500" v-else>
监控功能未开启请查看官网文档 https://githubim.com
</div>
</div>
</template>

View File

@@ -0,0 +1,3 @@
<template>
开发中敬请期待
</template>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { onMounted, ref, nextTick } from 'vue';
import APIClient from '../../services/APIClient';
import App from '../../services/App';
import API from '../../services/API';
class LabelInput {
name: string = ""
operator: string = ""
value: string = ""
}
const logs = ref(new Array<string>()) // 日志行
const labelInputs = ref(new Array<LabelInput>()) // 属性输入
const nodes = ref(new Array()) // 节点列表
const nodeId = ref("") // 节点ID
const logLevel = ref("") // 日志等级
const time = ref("1h") // 时间
const searchValue = ref("") // 搜索输入
const tail = ref(false) // 是否实时刷新
const ws = ref<WebSocket>()
const preUrl = ref("")
// 创建WebSocket实例
let wsUrl = ""
if (APIClient.shared.config.apiURL.startsWith('http')) {
wsUrl = APIClient.shared.config.apiURL.replace('http', 'ws')
wsUrl = `${wsUrl}/cluster/logs/tail`
} else {
wsUrl = `ws://${window.location.host}/cluster/logs/tail`
}
onMounted(() => {
API.shared.simpleNodes().then((res) => {
nodes.value = res.data
}).catch((err) => {
alert(err)
})
search()
})
const search = () => {
if (ws.value) {
ws.value.close()
ws.value = undefined
}
const u = new URL(wsUrl)
u.searchParams.append("Authorization", `Bearer ${App.shard().loginInfo.token}`)
u.searchParams.append("level", logLevel.value)
u.searchParams.append("time", time.value)
u.searchParams.append("search", searchValue.value)
u.searchParams.append("tail", tail.value ? "1" : "0")
u.searchParams.append("node_id", nodeId.value.toString())
u.searchParams.append("labels", JSON.stringify(labelInputs.value.filter((labelInput) => {
return labelInput.name.length > 0 && labelInput.value.length > 0 && labelInput.operator.length > 0
}).map((labelInput) => {
return {
name: labelInput.name,
operator: labelInput.operator,
value: labelInput.value
}
})))
const reqUrl = u.toString()
if (reqUrl === preUrl.value) {
return
}
preUrl.value = reqUrl
let loading = true // 加载日志中
logs.value = new Array<string>() // 清空日志
ws.value = new WebSocket(reqUrl)
ws.value.onopen = function () {
console.log('WebSocket is open now.');
};
ws.value.onmessage = function (event) {
console.log('WebSocket message received:', event.data);
if (loading) {
const results = JSON.parse(event.data)
if (!tail.value) {
logs.value.push(...results.map((log: string) => log.replace(/\n/g, '')));
}
loading = false
scrollToBottom()
}
if (tail.value) {
const results = JSON.parse(event.data)
logs.value.push(...results.map((log: string) => log.replace(/\n/g, '')));
scrollToBottom()
}
};
ws.value.onclose = function (event) {
console.log('WebSocket is closed now.', event);
};
}
const scrollToBottom = () => {
nextTick(() => {
const el = document.querySelector("#logBox")
if (el) {
el.scrollTop = el.scrollHeight
}
})
}
const onAddLabelInput = () => {
const labelInput = new LabelInput()
labelInput.operator = "="
labelInputs.value.push(labelInput)
}
const onRemoveLabelInput = (index: number) => {
labelInputs.value.splice(index, 1)
search()
}
</script>
<template>
<div class="grid grid-rows-[auto,1fr,auto]">
<div class="flex flex-wrap gap-4 mb-4">
<div class="flex text-sm ml-4 items-center">
<label>节点</label>
<select class="select select-bordered select-sm ml-2 max-w-xs " v-model="nodeId" v-on:change="search">
<option selected value="">All</option>
<option v-for="node in nodes" :value="node.id">{{ node.id }}</option>
</select>
</div>
<div class="flex text-sm ml-4 items-center">
<label>日志等级</label>
<select class="select select-bordered select-sm ml-2 max-w-xs " v-model="logLevel" v-on:change="search">
<option selected value="">All</option>
<option value="info">Info</option>
<option value="warn">Warn</option>
<option value="error">Error</option>
<option value="trace">Trace</option>
</select>
</div>
<div class="flex items-center text-sm ml-4">
<label>时间</label>
<select class="select select-bordered select-sm ml-2 max-w-xs" v-model="time" v-on:change="search">
<option selected value="5m">5分钟</option>
<option value="30m">30分钟</option>
<option value="1h">1小时</option>
<option value="6h">6小时</option>
<option value="12h">12小时</option>
<option value="24h">24小时</option>
</select>
</div>
<div class="flex items-center text-sm ml-4">
<label>搜索</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2" v-model="searchValue"
v-on:change="search" />
</div>
<div class="text-sm ml-4 flex items-center">
<label>实时刷新</label>
<input type="checkbox" class="checkbox ml-2" v-model="tail" />
</div>
<div class="text-sm ml-4 flex items-center">
<label>属性</label>
<div class="flex flex-wrap">
<div class="text-sm ml-2 border grid-rows-[auto,1fr] w-60 rounded"
v-for="(labelInput, index) in labelInputs">
<div class="flex justify-between border-b-[0.02rem] h-8 items-center">
<div class="ml-2">属性筛选</div>
<div class="cursor-pointer mr-2" v-on:click="() => onRemoveLabelInput(index)">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-x">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</div>
</div>
<div class="grid grid-rows-3 p-2 space-y-1">
<div class="flex items-center">
<div class="w-20">属性名</div>
<input type="text" class="input input-bordered w-28 h-8 text-sm"
v-model="labelInput.name" v-on:change="search" />
</div>
<div class="flex items-center">
<div class="w-20">操作</div>
<select class="select select-bordered select-sm w-28 h-8" v-model="labelInput.operator">
<option selected value="=">等于</option>
<option value="~~">包含</option>
<option value="!=">不等于</option>
<option value="!~">不包含</option>
</select>
</div>
<div class="flex items-center">
<div class="w-20">属性值</div>
<input type="text" class="input input-bordered w-28 h-8 text-sm"
v-model="labelInput.value" v-on:change="search" />
</div>
</div>
</div>
<button class="btn btn-square btn-md ml-2" v-on:click="onAddLabelInput">添加</button>
</div>
</div>
</div>
<!-- <div class="mockup-code overflow-scroll text-sm w-full h-full">
<pre v-for="log in logs">
<code >{{ log }}</code>
</pre>
</div> -->
<div class="overflow-hidden">
<div class="coding inverse-toggle px-5 pt-4 shadow-lg text-gray-100 text-sm font-mono subpixel-antialiased
bg-gray-800 pb-6 pt-4 rounded-lg leading-normal overflow-hidden h-full">
<div class="top mb-2 flex">
<div class="h-3 w-3 bg-red-500 rounded-full"></div>
<div class="ml-2 h-3 w-3 bg-orange-300 rounded-full"></div>
<div class="ml-2 h-3 w-3 bg-green-500 rounded-full"></div>
</div>
<div class="mt-4 overflow-scroll h-full" id="logBox">
<p class="text whitespace-nowrap leading-6" v-for="log in logs">
{{ log }}
</p>
<br /> <br />
</div>
</div>
</div>
<div>
<br />
</div>
</div>
</template>

View File

@@ -1,187 +1,149 @@
<script setup lang="ts">
import { monitorApi } from '@/api/modules/monitor-api';
// 常量
import {APP_DOC, LATEST_TIME} from '@/constants';
import MonitorPanel from '../../components/MonitorPanel.vue'
import { onMounted, ref } from 'vue';
import API from '../../services/API';
import { Series, setSeries } from '../../services/Model';
import App from '../../services/App';
import { setSeries } from '@/utils';
const latest = ref<number>(60 * 5) // 最近时间
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import { useUserStore } from '@/stores/modules/user';
const intranetIncomingBytesRateRef = ref<Series[]>([])
const intranetOutgoingBytesRateRef = ref<Series[]>([])
const userStore = useUserStore();
const extranetIncomingBytesRateRef = ref<Series[]>([])
const extranetOutgoingBytesRateRef = ref<Series[]>([])
const memstatsAllocBytes = ref<Series[]>([])
const goroutines = ref<Series[]>([])
const formOptions = reactive<VxeFormProps<any>>({
data: {
latest: 300
},
items: [
{
field: 'latest',
title: '时间',
itemRender: {
name: 'ElSelect',
props: {
placeholder: '请选择',
style: {
width: '180px'
}
},
options: LATEST_TIME
}
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
loadMetrics();
},
/** 重置 **/
reset() {
formOptions.data = {
latest: 300
};
loadMetrics();
}
};
const gcDurationSecondsCount = ref<Series[]>([])
const cpuPercent = ref<Series[]>([])
interface Series {
name?: string;
type?: string;
data: Point[];
}
interface Point {
timestamp?: number;
value?: number;
}
const intranetIncomingBytesRateRef = ref<Series[]>([]);
const intranetOutgoingBytesRateRef = ref<Series[]>([]);
const extranetIncomingBytesRateRef = ref<Series[]>([]);
const extranetOutgoingBytesRateRef = ref<Series[]>([]);
const memstatsAllocBytes = ref<Series[]>([]);
const goroutines = ref<Series[]>([]);
const gcDurationSecondsCount = ref<Series[]>([]);
const cpuPercent = ref<Series[]>([]);
const filefdAllocated = ref<Series[]>([]); // 文件打开数
const loadMetrics = () => {
monitorApi.systemMetrics(formOptions.data).then(res => {
intranetIncomingBytesRateRef.value = [];
intranetOutgoingBytesRateRef.value = [];
extranetIncomingBytesRateRef.value = [];
extranetOutgoingBytesRateRef.value = [];
memstatsAllocBytes.value = [];
goroutines.value = [];
gcDurationSecondsCount.value = [];
cpuPercent.value = [];
filefdAllocated.value = [];
var filefdAllocatedData = [];
for (let index = 0; index < res.length; index++) {
const d = res[index];
setSeries('intranet_incoming_bytes_rate', d, intranetIncomingBytesRateRef.value);
setSeries('intranet_outgoing_bytes_rate', d, intranetOutgoingBytesRateRef.value);
setSeries('extranet_incoming_bytes_rate', d, extranetIncomingBytesRateRef.value);
setSeries('extranet_outgoing_bytes_rate', d, extranetOutgoingBytesRateRef.value);
setSeries('memstats_alloc_bytes', d, memstatsAllocBytes.value);
setSeries('goroutines', d, goroutines.value);
setSeries('gc_duration_seconds_count', d, gcDurationSecondsCount.value);
setSeries('cpu_percent', d, cpuPercent.value);
filefdAllocatedData.push({ timestamp: d.timestamp, value: d.filefd_allocated });
}
filefdAllocated.value = [
{
name: '文件打开数',
data: filefdAllocatedData
}
];
});
};
const isShow = computed(() => {
return userStore.systemSetting.prometheusOn;
});
const filefdAllocated = ref<Series[]>([]) // 文件打开数
onMounted(() => {
// 是否开启prometheus
if (!userStore.systemSetting.prometheusOn) {
return;
}
if (!App.shard().systemSetting.prometheusOn) {
return
}
loadMetrics()
})
const loadMetrics = () => {
API.shared.systemMetrics(latest.value).then((res) => {
intranetIncomingBytesRateRef.value = []
intranetOutgoingBytesRateRef.value = []
extranetIncomingBytesRateRef.value = []
extranetOutgoingBytesRateRef.value = []
memstatsAllocBytes.value = []
goroutines.value = []
gcDurationSecondsCount.value = []
cpuPercent.value = []
filefdAllocated.value = []
var filefdAllocatedData = []
for (let index = 0; index < res.length; index++) {
const d = res[index];
setSeries("intranet_incoming_bytes_rate", d, intranetIncomingBytesRateRef.value)
setSeries("intranet_outgoing_bytes_rate", d, intranetOutgoingBytesRateRef.value)
setSeries("extranet_incoming_bytes_rate", d, extranetIncomingBytesRateRef.value)
setSeries("extranet_outgoing_bytes_rate", d, extranetOutgoingBytesRateRef.value)
setSeries("memstats_alloc_bytes", d, memstatsAllocBytes.value)
setSeries("goroutines", d, goroutines.value)
setSeries("gc_duration_seconds_count", d, gcDurationSecondsCount.value)
setSeries("cpu_percent", d, cpuPercent.value)
filefdAllocatedData.push({ timestamp: d.timestamp, value: d.filefd_allocated })
}
filefdAllocated.value = [{
name: "文件打开数",
data: filefdAllocatedData
}]
}).catch((err) => {
alert(err)
})
}
const onLatestChange = (e: any) => {
latest.value = e.target.value
loadMetrics()
}
const onRefresh = () => {
loadMetrics()
}
loadMetrics();
});
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
<el-button native-type="reset">重置</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<div v-if="isShow" class="flex-1 card overflow-hidden">
<el-scrollbar>
<div class="flex flex-wrap justify-left">
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="intranetIncomingBytesRateRef" title="内网流入流量" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="intranetOutgoingBytesRateRef" title="内网流流量" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="extranetIncomingBytesRateRef" title="外网流入流量" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="extranetOutgoingBytesRateRef" title="外网流出流量" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="memstatsAllocBytes" title="内存" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="goroutines" title="协程" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="gcDurationSecondsCount" title="GC" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="cpuPercent" title="CPU" />
</div>
<div class="w-320px h-320px shadow-md p-12px mr-12px mb-12px">
<wk-monitor-panel :data="filefdAllocated" title="文件打开数" />
</div>
<div class="overflow-auto h-5/6">
<div class="flex">
<div class="text-sm ml-10">
<select class="select select-bordered max-w-xs select-sm w-40 ml-2" v-on:change="onLatestChange">
<option selected :value="60 * 5">过去5分钟</option>
<option :value="60 * 30">过去30分钟</option>
<option :value="60 * 60">过去1小时</option>
<option :value="60 * 60 * 6">过去6小时</option>
<option :value="60 * 60 * 24">过去1天</option>
<option :value="60 * 60 * 24 * 3">过去3天</option>
<option :value="60 * 60 * 24 * 7">过去7天</option>
</select>
</div>
<button class="btn btn-sm ml-10" v-on:click="onRefresh">立马刷新</button>
</div>
<br />
<div class="flex flex-wrap justify-left" v-if="App.shard().systemSetting.prometheusOn">
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="intranetIncomingBytesRateRef" title="内网流流量" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="intranetOutgoingBytesRateRef" title="内网流出流量" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="extranetIncomingBytesRateRef" title="外网流入流量" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="extranetOutgoingBytesRateRef" title="外网流出流量" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="memstatsAllocBytes" title="内存" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="goroutines" title="协程" />
</div>
</div class="pl-5">
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="gcDurationSecondsCount" title="GC" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="cpuPercent" title="CPU" />
</div>
</div>
<div class="pl-5">
<div class="w-[30%] h-[20rem] min-w-[20rem] shadow-md p-5">
<MonitorPanel :data="filefdAllocated" title="文件打开数" />
</div>
</div>
</div>
<div class="text text-center mt-10 text-red-500" v-else>
监控功能未开启请查看官网文档 https://githubim.com
</div>
</el-scrollbar>
</div>
<div v-else class="flex-1 card flex items-center justify-center overflow-hidden">
监控功能未开启请查看官网文档
<el-link type="primary" :href="APP_DOC" target="_blank">{{ APP_DOC }}</el-link>
</div>
</wk-page>
</template>
<style scoped lang="scss"></style>
<route lang="yaml">
meta:
title: 系统
</route>
</template>

View File

@@ -0,0 +1,255 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Graph } from '@antv/x6'
import { register } from '@antv/x6-vue-shape'
import SpanNode from '../../components/SpanNode.vue'
import TextNode from '../../components/TextNode.vue'
import RecvackTable from '../../components/RecvackTable.vue'
import API from "../../services/API";
import { useRouter } from "vue-router";
import App from "../../services/App";
const clientMsgNo = ref('')
const messageId = ref(0)
const nodeWidth = 180
const nodeHeight = 70
const error = ref('')
const recvackResult = ref()
const router = useRouter()
const query = router.currentRoute.value.query; //查询参数
if (query.clientMsgNo) {
clientMsgNo.value = query.clientMsgNo as string
}
const modalContent = ref('')
register({
shape: 'spanNode',
width: nodeWidth,
height: nodeHeight,
component: SpanNode,
})
register({
shape: 'textNode',
width: nodeWidth,
height: nodeHeight,
component: TextNode,
})
onMounted(async () => {
App.shard().loadSystemSettingIfNeed()
if (clientMsgNo.value && clientMsgNo.value.length > 0) {
drawFlow()
}
});
const drawFlow = async () => {
const container = document.getElementById('container')
const containerWidth = container!.offsetWidth
const containerHeight = container!.offsetHeight
error.value = ''
const result = await requestMessageTrace({
width: containerWidth,
height: containerHeight
}).catch((e) => {
error.value = e.msg
})
const nodes = []
const edges = []
if (result.nodes) {
for (let i = 0; i < result.nodes.length; i++) {
const node = result.nodes[i]
nodes.push({
id: node.id,
shape: node.shape,
x: node.x,
y: node.y,
data: {
name: node.name,
time: node.time,
icon: node.icon,
duration: node.duration,
description: node.description,
data: node.data,
}
})
}
}
if (result.edges) {
for (let i = 0; i < result.edges.length; i++) {
const edge = result.edges[i]
const edgeObj = {
source: edge.source,
target: edge.target,
connector: { name: 'smooth' },
attrs: {
line: {
stroke: '#1890ff',
targetMarker: 'classic',
strokeDasharray: 0,
},
},
}
if (edge.shape == "dashed") {
edgeObj.attrs.line.strokeDasharray = 5
}
edges.push(edgeObj)
}
}
const graph = new Graph({
container: container!,
width: containerWidth,
height: containerHeight,
panning: true,
background: {
color: '#F2F7FA',
},
grid: true,
mousewheel: true,
})
graph.addNodes(nodes)
graph.addEdges(edges)
graph.on('node:click', async ({ node }) => {
const nodeId = node.id as string
if (nodeId === "processMessage") {
const data = node.data.data
modalContent.value = `
发送者: ${data.uid} <br>
发送设备: ${data.deviceId} <br>
设备类型: ${data.deviceFlag} <br>
设备等级: ${data.deviceLevel} <br>
接受频道: ${data.channelId} <br>
频道类型: ${data.channelType} <br>
`
}else if (nodeId.startsWith("deliverOnline") || nodeId.startsWith("deliverOffline")) {
const data = node.data.data
var uids: string[] = []
if( data.uids) {
data.uids.split(',').forEach((uid: string) => {
uids.push(uid)
})
}
modalContent.value = '<div class="flex flex-wrap space-x-2">'
for (let i = 0; i < uids.length; i++) {
modalContent.value += `
<a href="#" class="inline-block"> ${uids[i]}</a>
`
}
modalContent.value += '</div>'
} else if (nodeId.startsWith('processRecvack')) {
const data = node.data.data
const nodeId = data.nodeId
const messageId = data.messageId
recvackResult.value = await requestMessageRecvackTraces({
nodeId: nodeId,
messageId: messageId,
})
if(recvackResult.value) {
const dialog = document.getElementById('recvackTable') as HTMLDialogElement;
dialog.showModal();
}
return
} else {
return
}
const dialog = document.getElementById('content') as HTMLDialogElement;
dialog.showModal();
})
}
const requestMessageTrace = ({
width,
height,
}: any) => {
return API.shared.messageTraces({
clientMsgNo: clientMsgNo.value,
messageId: messageId.value,
width: width,
height: height,
since: 60 * 60 * 24,
})
}
const requestMessageRecvackTraces = ({
nodeId,
messageId,
}:any) => {
return API.shared.messageRecvackTraces({
nodeId: nodeId,
messageId: messageId,
since: 60 * 60 * 24,
})
}
const onSearch = (e: any) => {
if (!App.shard().systemSetting.messageTraceOn) {
error.value = '消息追踪功能未开启,请查看官网文档 https://githubim.com'
return
}
clientMsgNo.value = e.target.value
drawFlow()
}
</script>
<template>
<div class="overflow-x-auto h-5/6">
<div class="flex flex-wrap gap-4">
<div class="text-sm ml-4">
<label>消息编号</label>
<input type="text" placeholder="输入" class="input input-bordered select-sm ml-2" v-model="clientMsgNo"
v-on:change="onSearch" />
</div>
<div class="text text-red-500">{{ error }}</div>
</div>
<br />
<div id="container" class="w-full h-full"></div>
<dialog id="content" class="modal">
<div class="modal-box flex flex-wrap gap-2">
<div v-html="modalContent" class="break-words"></div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<dialog id="recvackTable" class="modal">
<div class="modal-box">
<div class="min-w-[40rem]">
<RecvackTable :data="recvackResult" />
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -1,39 +0,0 @@
<script name="Content" lang="ts" setup>
interface IProps {
contentData: string;
}
/**
* Props
*/
withDefaults(defineProps<IProps>(), {
contentData: ''
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
</script>
<template>
<vxe-modal
v-model="showModel"
title="内容"
:width="560"
:height="460"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<div class="flex-1 card pt-0px overflow-hidden">
<div v-html="contentData" class="break-words"></div>
</div>
</wk-page>
</vxe-modal>
</template>
<style lang="scss" scoped>
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,99 +0,0 @@
<script name="RecvackTraces" lang="ts" setup>
// API 接口
import { monitorApi } from '@/api/modules/monitor-api.ts';
import type { VxeGridInstance, VxeGridProps } from 'vxe-table';
interface IProps {
nodeId: string;
messageId: string;
}
/**
* Props
*/
const props = withDefaults(defineProps<IProps>(), {
nodeId: '',
messageId: ''
});
const showModel = defineModel<boolean>();
const loadingModel = ref(false);
watch(
() => showModel.value,
val => {
if (val) {
if (props.nodeId) {
tableRef.value?.commitProxy('query');
}
}
}
);
/**
* 表格
*/
interface ITableItem {
conn_id: string;
uid: string;
device_id: string;
time: string;
}
const tableRef = ref<VxeGridInstance<ITableItem>>();
const apiLoadList = async () => {
const res = await monitorApi.messageRecvackTraces({ ...props, since: 60 * 60 * 24 });
return res.data || [];
};
const gridOptions = reactive<VxeGridProps<ITableItem>>({
border: true,
showOverflow: true,
height: 'auto',
stripe: true,
rowConfig: { isCurrent: true, isHover: true },
scrollY: { enabled: true, gt: 0 },
proxyConfig: {
ajax: {
query: () => {
return apiLoadList();
}
}
},
columns: [
{ type: 'seq', title: '序号', width: 64 },
{ field: 'replica_id', title: '连接ID', minWidth: 120 },
{ field: 'role_format', title: '用户UID', minWidth: 120 },
{ field: 'last_msg_seq', title: '设备ID', minWidth: 120 },
{ field: 'last_msg_time_format', title: '收到时间', minWidth: 120 }
]
});
</script>
<template>
<vxe-modal
v-model="showModel"
title="内容"
:width="860"
:height="560"
:confirm-closable="false"
:padding="false"
:loading="loadingModel"
show-maximize
>
<wk-page class="flex-col wk-page-bg">
<div class="flex-1 card pt-0px overflow-hidden">
<!-- S 表格 -->
<div class="flex-1 card pt-0px overflow-hidden">
<vxe-grid ref="tableRef" v-bind="gridOptions"></vxe-grid>
</div>
<!-- E 表格 -->
</div>
</wk-page>
</vxe-modal>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>

View File

@@ -1,22 +0,0 @@
<script name="SpanNode" lang="ts" setup>
import { inject, onMounted, ref } from 'vue';
const getNode = inject<() => any>('getNode');
const data = ref({ name: '', time: '', icon: '', duration: 0, description: '' });
onMounted(() => {
const node = getNode!();
data.value = node.data;
});
</script>
<template>
<div class="w-full h-full flex bg-white p-4px">
<div v-if="data.icon" v-html="data.icon" class="pr-4px pt-8px"></div>
<div class="flex-1">
<div class="font-semibold flex items-center text-16px justify-between">
<div>{{ data.name }}</div>
</div>
<div class="text text-sm text-gray-400 flex items-center">{{ data.description }}</div>
<div class="text text-sm text-gray-400 flex items-center">{{ data.time }}</div>
</div>
</div>
</template>

View File

@@ -1,17 +0,0 @@
<script name="TextNode" setup lang="ts">
import { inject, onMounted, ref } from 'vue';
const getNode = inject<() => any>('getNode');
const data = ref({ name: '' });
onMounted(() => {
const node = getNode!();
data.value = node.data;
});
</script>
<template>
<div class="w-full h-full border border-blue-500 rounded-md shadow-md bg-white">
<div class="w-full h-full font-semibold flex items-center text-[0.9rem] justify-center">
<div>{{ data.name }}</div>
</div>
</div>
</template>

View File

@@ -1,252 +0,0 @@
<script lang="ts" setup>
// API接口
import { monitorApi } from '@/api/modules/monitor-api';
import { VxeFormListeners, VxeFormProps } from 'vxe-pc-ui';
import SpanNode from './components/SpanNode.vue';
import TextNode from './components/TextNode.vue';
import Content from './components/Content.vue';
import RecvackTraces from './components/RecvackTraces.vue';
import { Graph } from '@antv/x6';
import { register } from '@antv/x6-vue-shape';
import { useRoute } from 'vue-router';
const route = useRoute();
/**
* 查询条件
* */
const formOptions = reactive<VxeFormProps<any>>({
data: {
message_id: 0,
client_msg_no: null
},
items: [
{
field: 'client_msg_no',
title: '消息编号',
itemRender: {
name: 'ElInput',
props: { placeholder: '请输入消息编号' }
}
},
{
align: 'center',
slots: { default: 'action' }
}
]
});
/** 搜索事件 **/
const formEvents: VxeFormListeners<any> = {
/** 查询 **/
submit() {
drawFlow();
}
};
const nodeWidth = 180;
const nodeHeight = 70;
const loading = ref(false);
const error = ref('');
/**
* 查看节点内容
*/
const contentModal = ref(false);
const contentHtml = ref('');
/**
*
*/
const recvackModal = ref(false);
const recvackNodeId = ref('');
const recvackMessageId = ref('');
register({
shape: 'spanNode',
width: nodeWidth,
height: nodeHeight,
component: SpanNode
});
register({
shape: 'textNode',
width: nodeWidth,
height: nodeHeight,
component: TextNode
});
const requestMessageTrace = ({ width, height }: any) => {
return monitorApi.messageTraces({
...formOptions.data,
width: width,
height: height,
since: 60 * 60 * 24
});
};
const drawFlow = async () => {
const container = document.getElementById('container');
const containerWidth = container!.offsetWidth;
const containerHeight = container!.offsetHeight;
error.value = '';
loading.value = true;
const result = await requestMessageTrace({
width: containerWidth,
height: containerHeight
}).catch(e => {
loading.value = false;
error.value = e.msg;
});
loading.value = false;
const nodes = [];
const edges = [];
if (result.nodes) {
for (let i = 0; i < result.nodes.length; i++) {
const node = result.nodes[i];
nodes.push({
id: node.id,
shape: node.shape,
x: node.x,
y: node.y,
data: {
name: node.name,
time: node.time,
icon: node.icon,
duration: node.duration,
description: node.description,
data: node.data
}
});
}
}
if (result.edges) {
for (let i = 0; i < result.edges.length; i++) {
const edge = result.edges[i];
const edgeObj = {
source: edge.source,
target: edge.target,
connector: { name: 'smooth' },
attrs: {
line: {
stroke: '#1890ff',
targetMarker: 'classic',
strokeDasharray: 0
}
}
};
if (edge.shape == 'dashed') {
edgeObj.attrs.line.strokeDasharray = 5;
}
edges.push(edgeObj);
}
}
const graph = new Graph({
container: container!,
width: containerWidth,
height: containerHeight,
panning: true,
background: {
color: '#F2F7FA'
},
grid: true,
mousewheel: true
});
graph.addNodes(nodes);
graph.addEdges(edges);
graph.on('node:click', async ({ node }) => {
const nodeId = node.id as string;
if (nodeId === 'processMessage') {
const data = node.data.data;
contentHtml.value = `
发送者: ${data.uid} <br>
发送设备: ${data.deviceId} <br>
设备类型: ${data.deviceFlag} <br>
设备等级: ${data.deviceLevel} <br>
接受频道: ${data.channelId} <br>
频道类型: ${data.channelType} <br>
`;
} else if (nodeId.startsWith('deliverOnline') || nodeId.startsWith('deliverOffline')) {
const data = node.data.data;
let uids: string[] = [];
if (data.uids) {
data.uids.split(',').forEach((uid: string) => {
uids.push(uid);
});
}
contentHtml.value = '<div class="flex flex-wrap space-x-2">';
for (let i = 0; i < uids.length; i++) {
contentHtml.value += `
<a href="#" class="inline-block"> ${uids[i]}</a>
`;
}
contentHtml.value += '</div>';
} else if (nodeId.startsWith('processRecvack')) {
const data = node.data.data;
recvackNodeId.value = data.nodeId;
recvackMessageId.value = data.messageId;
recvackModal.value = true;
return;
} else {
return;
}
contentModal.value = true;
});
};
onMounted(() => {
if (route.query?.clientMsgNo) {
formOptions.data = {
...formOptions.data,
client_msg_no: route.query.clientMsgNo
};
drawFlow();
}
});
</script>
<template>
<wk-page class="flex-col">
<!-- S 查询条件 -->
<div class="mb-12px pt-4px pb-4px card">
<vxe-form v-bind="formOptions" v-on="formEvents">
<template #action>
<el-button native-type="submit" type="primary">查询</el-button>
</template>
</vxe-form>
</div>
<!-- E 查询条件 -->
<!-- S 消息轨迹 -->
<div v-loading="loading" class="flex-1 card overflow-hidden">
<div id="container" class="w-full h-full"></div>
</div>
<!-- E 消息轨迹 -->
<!-- 内容 -->
<Content v-model="contentModal" :content-data="contentHtml" />
<!-- 回执轨迹 -->
<RecvackTraces v-model="recvackModal" :node-id="recvackNodeId" :message-id="recvackMessageId" />
</wk-page>
</template>
<style scoped lang="scss">
.wk-page-bg {
background-color: var(--el-bg-color-page);
}
</style>
<route lang="yaml">
meta:
title: 消息追踪
</route>

View File

@@ -1,84 +1,135 @@
import { useUserStore } from '@/stores/modules/user';
import { useAuthStore } from '@/stores/modules/auth';
import { createRouter, createWebHistory } from 'vue-router'
import { LOGIN_URL, ROUTER_WHITE_LIST } from '@/config';
import Login from '../pages/login/Login.vue'
import { createRouter, createWebHistory } from 'vue-router';
// ==================== cluster ====================
import Node from '../pages/cluster/Node.vue'
import Slot from '../pages/cluster/Slot.vue'
import Channel from '../pages/cluster/Channel.vue'
import Config from '../pages/cluster/Config.vue'
import Log from '../pages/cluster/Log.vue'
// import NodeDetail from '../pages/cluster/NodeDetail.vue'
import NProgress from '@/utils/nprogress';
// ==================== data ====================
import User from '../pages/data/User.vue'
import Device from '../pages/data/Device.vue'
import Connection from '../pages/data/Connection.vue'
import Message from '../pages/data/Message.vue'
import ChannelForData from '../pages/data/Channel.vue'
import Conversation from '../pages/data/Conversation.vue'
// ==================== monitor ====================
import MonitorApp from '../pages/monitor/App.vue'
import MonitorCluster from '../pages/monitor/Cluster.vue'
import MonitorSystem from '../pages/monitor/System.vue'
import TraceDB from '../pages/monitor/Trace.vue'
import Logs from '../pages/monitor/Logs.vue'
import routes from './routers';
/**
* @description 📚 路由参数配置简介
* @param path ==> 菜单路径
* @param name ==> 菜单别名
* @param redirect ==> 重定向地址
* @param component ==> 视图文件路径
* @param meta ==> 菜单信息
* @param meta.icon ==> 菜单图标
* @param meta.title ==> 菜单标题
* @param meta.activeMenu ==> 当前路由为详情页时,需要高亮的菜单
* @param meta.isLink ==> 是否外链
* @param meta.isHide ==> 是否隐藏
* @param meta.isFull ==> 是否全屏(示例:数据大屏页面)
* @param meta.isAffix ==> 是否固定在 tabs nav
* @param meta.isKeepAlive ==> 是否缓存
* */
const router = createRouter({
history: createWebHistory('/web'),
routes,
strict: false,
scrollBehavior: () => ({ left: 0, top: 0 })
});
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// ==================== cluster ====================
{
path: '/',
name: 'home',
component: Node
},
{
path: '/login',
name: 'login',
component: Login
},
{
path: '/cluster/nodes',
name: 'node',
component: Node
},
// {
// path: '/cluster/node/detail',
// name: 'nodeDetail',
// component: NodeDetail
// },
{
path: '/cluster/slots',
name: 'slot',
component: Slot
},
{
path: '/cluster/channels',
name: 'channel',
component: Channel
},
{
path: '/cluster/config',
name: 'config',
component: Config
},
{
path: '/cluster/log',
name: 'clusterlog',
component: Log
},
/**
* @description 路由拦截 beforeEach
* */
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const userStore = useUserStore();
// ==================== data ====================
{
path: '/data/connection',
name: 'dataConnection',
component: Connection
},
{
path: '/data/user',
name: 'dataUser',
component: User
},
{
path: '/data/device',
name: 'dataDevice',
component: Device
},
{
path: '/data/message',
name: 'dataMessage',
component: Message
},
{
path: '/data/channel',
name: 'dataChannel',
component: ChannelForData
},
{
path: '/data/conversation',
name: 'dataConversation',
component: Conversation
},
// ==================== monitor ====================
{
path: '/monitor/app',
name: 'monitorApp',
component: MonitorApp
},
{
path: '/monitor/cluster',
name: 'monitorCluster',
component: MonitorCluster
},
{
path: '/monitor/system',
name: 'monitorSystem',
component: MonitorSystem
},
{
path: '/monitor/trace',
name: 'traceDB',
component: TraceDB
},
{
path: '/monitor/logs',
name: 'logs',
component: Logs
},
]
})
// NProgress 开始
NProgress.start();
/** 如果已经登录并存在登录信息后不能跳转到路由白名单,而是继续保持在当前页面 */
function toCorrectRoute() {
ROUTER_WHITE_LIST.includes(to.fullPath) ? next(from.fullPath) : next();
}
if (userStore.token) {
// 正常访问页面
if (!authStore.authMenuListGet.length) {
await authStore.getAuthMenuList();
}
toCorrectRoute();
} else {
if (to.path !== LOGIN_URL) {
if (ROUTER_WHITE_LIST.indexOf(to.path) !== -1) {
next();
} else {
next({ path: LOGIN_URL, replace: true });
}
} else {
next();
}
}
});
/**
* @description 路由跳转错误
* */
router.onError(error => {
NProgress.done();
console.warn('路由错误', error.message);
});
/**
* @description 路由跳转结束
* */
router.afterEach(() => {
NProgress.done();
});
export default router;
export default router

View File

@@ -1,15 +0,0 @@
import { staticRouter } from '@/router/static-router';
import { setupLayouts } from 'virtual:meta-layouts';
import generatedRoutes from 'virtual:generated-pages';
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = setupLayouts(
generatedRoutes.filter(item => {
return item.meta?.enabled !== false && item.meta?.constant !== true && item.meta?.layout !== false;
})
);
export default [...staticRouter, ...routes];

Some files were not shown because too many files have changed in this diff Show More