From 1c6107cb07f30db3402a7fa9cd66ffbfac5bebb3 Mon Sep 17 00:00:00 2001 From: hi2hi Date: Fri, 6 Dec 2024 05:38:12 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20=E5=85=BC=E5=AE=B9=E5=93=AA?= =?UTF-8?q?=E5=90=92V1=E7=9A=84=E6=95=B0=E6=8D=AE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + nginx-default.conf.template | 18 ++- public/config.js | 4 + src/App.vue | 2 +- src/config/index.js | 4 + src/store/index.js | 43 +++++- ...ezha-config.js => load-nezha-v0-config.js} | 27 ++++ src/utils/load-nezha-v1-config.js | 138 ++++++++++++++++++ src/utils/object-mapping.js | 99 +++++++++++++ src/utils/request.js | 35 +---- .../server-detail/server-monitor.vue | 9 +- .../server-list/server-list-item-bill.vue | 6 +- src/views/composable/server-bill-and-plan.js | 3 + src/views/composable/server-status.js | 4 +- src/views/home.vue | 31 +--- src/ws/index.js | 24 ++- vite.config.js | 5 + 17 files changed, 379 insertions(+), 74 deletions(-) rename src/utils/{load-nezha-config.js => load-nezha-v0-config.js} (71%) create mode 100644 src/utils/load-nezha-v1-config.js create mode 100644 src/utils/object-mapping.js diff --git a/.gitignore b/.gitignore index a547bf3..b3a7ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +demo # Editor directories and files .vscode/* diff --git a/nginx-default.conf.template b/nginx-default.conf.template index 2c6ee38..6b6ed18 100644 --- a/nginx-default.conf.template +++ b/nginx-default.conf.template @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80; server_name ${DOMAIN}; @@ -7,7 +12,18 @@ server { proxy_pass ${NEZHA}ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 兼容哪吒V1 + location /api/v1/ws/server { + proxy_pass ${NEZHA}api/v1/ws/server; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/public/config.js b/public/config.js index 96075e7..68f1461 100644 --- a/public/config.js +++ b/public/config.js @@ -16,9 +16,13 @@ window.$$nazhuaConfig = { // hideFilter: false, // 隐藏筛选 // hideTag: false, // 隐藏标签 // customCodeMap: {}, // 自定义的地图点信息 + // nezhaVersion: 'v1', // 哪吒版本 // apiMonitorPath: '/api/v1/monitor/{id}', // wsPath: '/ws', // nezhaPath: '/nezha/', // nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型 + // v1ApiMonitorPath: '/api/v1/service/{id}', + // v1WsPath: '/api/v1/ws/server', + // v1GroupPath: '/api/v1/server-group', // routeMode: 'h5', // 路由模式 }; diff --git a/src/App.vue b/src/App.vue index a1bc78f..13f58bd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -55,7 +55,7 @@ async function wsReconnect() { onMounted(async () => { handleSystem(); refreshTime(); - await store.dispatch('loadServers'); + await store.dispatch('initServerInfo'); msg.on('close', () => { console.log('ws closed'); wsReconnect(); diff --git a/src/config/index.js b/src/config/index.js index a7106c3..1b3a256 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -12,10 +12,14 @@ const config = { }, nazhua: { title: '哪吒监控', + nezhaVersion: 'v0', apiMonitorPath: '/api/v1/monitor/{id}', wsPath: '/ws', nezhaPath: '/nezha/', nezhaV0ConfigType: 'servers', + v1ApiMonitorPath: '/api/v1/service/{id}', + v1WsPath: '/api/v1/ws/server', + v1GroupPath: '/api/v1/server-group', // 解构载入自定义配置 ...(window.$$nazhuaConfig || {}), }, diff --git a/src/store/index.js b/src/store/index.js index e6c9851..da3e327 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -2,7 +2,13 @@ import { createStore, } from 'vuex'; import dayjs from 'dayjs'; -import loadNezhaConfig from '@/utils/load-nezha-config'; +import config from '@/config'; +import loadNezhaV0Config, { + loadServerGroup as loadNezhaV0ServerGroup, +} from '@/utils/load-nezha-v0-config'; +import { + loadServerGroup as loadNezhaV1ServerGroup, +} from '@/utils/load-nezha-v1-config'; import { msg, @@ -10,6 +16,7 @@ import { const defaultState = () => ({ init: false, + serverGroup: [], serverList: [], serverCount: { total: 0, @@ -35,9 +42,13 @@ function handleServerCount(servers) { return counts; } +let firstSetServers = true; const store = createStore({ state: defaultState(), mutations: { + SET_SERVER_GROUP(state, serverGroup) { + state.serverGroup = serverGroup; + }, SET_SERVERS(state, servers) { const newServers = [...servers]; newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex); @@ -58,8 +69,6 @@ const store = createStore({ }; if (oldItem?.PublicNote) { serverItem.PublicNote = oldItem.PublicNote; - } else { - serverItem.PublicNote = {}; } return serverItem; }); @@ -74,8 +83,20 @@ const store = createStore({ /** * 加载服务器列表 */ - async loadServers({ commit }) { - const serverResult = await loadNezhaConfig(); + async initServerInfo({ commit }) { + firstSetServers = true; + // 如果是v1版本的话,加载v1版本的数据 + if (config.nazhua.nezhaVersion === 'v1') { + loadNezhaV1ServerGroup().then((res) => { + if (res) { + commit('SET_SERVER_GROUP', res); + } + }); + return; + } + // 如果是v0版本的话,加载v0版本的数据 + // 加载初始化的服务器列表,需要其中的公开备注字段 + const serverResult = await loadNezhaV0Config(); if (!serverResult) { console.error('load server config failed'); return; @@ -87,6 +108,11 @@ const store = createStore({ }; return item; }) || []; + const res = loadNezhaV0ServerGroup(servers); + if (res) { + commit('SET_SERVER_GROUP', res); + } + firstSetServers = false; commit('SET_SERVERS', servers); }, /** @@ -104,7 +130,12 @@ const store = createStore({ }; return item; }) || []; - commit('UPDATE_SERVERS', servers); + if (firstSetServers) { + firstSetServers = false; + commit('SET_SERVERS', servers); + } else { + commit('UPDATE_SERVERS', servers); + } } }); }, diff --git a/src/utils/load-nezha-config.js b/src/utils/load-nezha-v0-config.js similarity index 71% rename from src/utils/load-nezha-config.js rename to src/utils/load-nezha-v0-config.js index 60969f8..003b997 100644 --- a/src/utils/load-nezha-config.js +++ b/src/utils/load-nezha-v0-config.js @@ -38,3 +38,30 @@ export default async () => fetch(config.nazhua.nezhaPath).then((res) => res.text } return null; }).catch(() => null); + +/** + * 获取标签列表 + */ +export const loadServerGroup = (services) => { + const tagMap = {}; + services.forEach((i) => { + if (i.Tag) { + if (!tagMap[i.Tag]) { + tagMap[i.Tag] = []; + } + tagMap[i.Tag].push(i); + } + }); + const tagList = []; + Object.entries(tagMap).forEach(([tag, serviceIds]) => { + tagList.push({ + name: tag, + count: serviceIds.length, + servers: serviceIds, + group: { + name: tag, + }, + }); + }); + return tagList; +}; diff --git a/src/utils/load-nezha-v1-config.js b/src/utils/load-nezha-v1-config.js new file mode 100644 index 0000000..1aecae9 --- /dev/null +++ b/src/utils/load-nezha-v1-config.js @@ -0,0 +1,138 @@ +/** + * V1版数据加载 + */ +import store from '@/store'; +import config from '@/config'; +import request from '@/utils/request'; +import { Mapping } from '@/utils/object-mapping'; + +/** + * 字段映射 + */ +export const SERVER_FIELD_MAPS = { + ID: 'id', + CreatedAt: undefined, + UpdatedAt: undefined, + DeletedAt: undefined, + Name: 'name', + Tag: '_$function|queryGroup|id', + DisplayIndex: 'display_index', + HideForGuest: undefined, + EnableDDNS: undefined, + Host: '_$mapping|HOST_FIELD_MAPS', + State: '_$mapping|STATE_FIELD_MAPS', + LastActive: 'last_active', +}; +export const HOST_FIELD_MAPS = { + Platform: 'host.platform', + PlatformVersion: 'host.platform_version', + CPU: 'host.cpu', + MemTotal: 'host.mem_total', + DiskTotal: 'host.disk_total', + SwapTotal: 'host.swap_total', + Arch: 'host.arch', + Virtualization: 'host.virtualization', + BootTime: 'host.boot_time', + CountryCode: 'country_code', + Version: 'host.version', + GPU: 'host.gpu', +}; +export const STATE_FIELD_MAPS = { + CPU: 'state.cpu', + MemUsed: 'state.mem_used', + SwapUsed: 'state.swap_used', + DiskUsed: 'state.disk_used', + NetInTransfer: 'state.net_in_transfer', + NetOutTransfer: 'state.net_out_transfer', + NetInSpeed: 'state.net_in_speed', + NetOutSpeed: 'state.net_out_speed', + Uptime: 'state.uptime', + Load1: 'state.load_1', + Load5: 'state.load_5', + Load15: 'state.load_15', + TcpConnCount: 'state.tcp_conn_count', + UdpConnCount: 'state.udp_conn_count', + ProcessCount: 'state.process_count', + Temperatures: 'state.temperatures', + GPU: 'state.gpu', +}; + +/** + * 魔法方法 + */ +const magics = { + HOST_FIELD_MAPS, + STATE_FIELD_MAPS, + queryGroup: (id) => { + const groupItem = store.state.serverGroup?.find?.((i) => { + if (i.servers) { + return i.servers.includes(id); + } + return false; + }); + return groupItem?.name; + }, +}; + +/** + * 处理V1版数据 + * @param {Object} v1Data V1版数据 + * @return {Object} V0版数据 + */ +export const handelV1toV0 = (v1Data) => { + const v0Data = {}; + Object.keys(SERVER_FIELD_MAPS).forEach((key) => { + if (SERVER_FIELD_MAPS[key] === undefined) { + return; + } + if (SERVER_FIELD_MAPS[key].includes('_$')) { + const $magic = SERVER_FIELD_MAPS[key].split('|'); + switch ($magic[0]) { + case '_$function': + if ($magic.length >= 3 && magics[$magic[1]]) { + v0Data[key] = magics[$magic[1]]( + Mapping.mapping(v1Data, $magic[2]), + ); + } else { + v0Data[key] = undefined; + } + break; + case '_$mapping': + v0Data[key] = Mapping.each(magics[$magic[1]], v1Data); + break; + default: + break; + } + return; + } + v0Data[key] = Mapping.mapping(v1Data, SERVER_FIELD_MAPS[key]); + }); + if (v1Data.public_note) { + try { + v0Data.PublicNote = JSON.parse(v1Data.public_note); + } catch (e) { + v1Data.PublicNote = null; + } + } else { + v1Data.PublicNote = null; + } + return v0Data; +}; + +export const loadServerGroup = async () => request({ + url: config.nazhua.v1GroupPath, + type: 'GET', +}).then((res) => { + if (res.status === 200) { + const list = res.data?.data || []; + return list.map((i) => { + const item = { + ...i, + name: i?.group?.name, + count: i?.servers?.length, + }; + return item; + }); + } + return null; +}).catch(() => null); diff --git a/src/utils/object-mapping.js b/src/utils/object-mapping.js new file mode 100644 index 0000000..9d2e87b --- /dev/null +++ b/src/utils/object-mapping.js @@ -0,0 +1,99 @@ +/** + * 对象映射封装 + */ +class Mapping { + /** + * 字符串映射对象 + * + * @param {Record} obj 查找的对象 + * @param {string} key 查找的属性 + * + * @return {any} + */ + static mapping(obj, key) { + // 检查 obj 是否为对象,如果不是,返回 undefined + if (!obj || typeof obj !== 'object') { + return undefined; + } + // 检查 key 是否为字符串,如果不是,返回 undefined + if (typeof key !== 'string') { + return undefined; + } + // 检查 key 是否包含非法字符,如果包含,返回 undefined + if (key.includes('..') || key.startsWith('.') || key.endsWith('.')) { + return undefined; + } + // 如果 key 包含 '.',使用 reduce 方法递归获取嵌套属性值 + if (key.includes('.')) { + return key.split('.').reduce((val, k) => (val !== undefined ? Mapping.get(val, k) : undefined), obj); + } + // 如果 key 不包含 '.',直接获取属性值 + return Mapping.get(obj, key); + } + + /** + * 获取数据 + * 支持处理数组指针 + * @param {Record | any[]} obj 属性对象 + * @param {string} key 属性名称 + * @return {any} + */ + static get(obj, key) { + if (!obj || typeof obj !== 'object' || !key) { + return undefined; + } + const indexMatch = key.match(/\[(\d+)\]/); + if (indexMatch) { + const [fullMatch, indexStr] = indexMatch; + const index = Number(indexStr); + const matchIndex = key.indexOf(fullMatch); + if (matchIndex === 0) { + if (Array.isArray(obj) && index < obj.length) { + const val = obj[index]; + const restKey = key.slice(fullMatch.length); + return restKey ? Mapping.get(val, restKey) : val; + } + } else { + const pre = key.slice(0, matchIndex); + const list = obj[pre]; + if (Array.isArray(list) && index < list.length) { + const val = list[index]; + const restKey = key.slice(matchIndex + fullMatch.length); + return restKey ? Mapping.get(val, restKey) : val; + } + } + return undefined; + } + return obj[key]; + } + + /** + * 数据根据key的映射进行组装 + * + * @param {KeyMap} keys 映射对象 + * @param {Record} data 数据对象 + * + * @return {Record} + */ + static each(keys, data) { + // 检查 keys 是否为对象,如果不是,返回 undefined + if (!keys || typeof keys !== 'object') { + return undefined; + } + // 检查 data 是否为对象,如果不是,返回 undefined + if (!data || typeof data !== 'object') { + return undefined; + } + return Object.entries(keys).reduce((acc, [key, value]) => { + if (typeof value === 'string') { + acc[key] = Mapping.mapping(data, value); + } + return acc; + }, {}); + } +} +const { mapping } = Mapping; + +export { Mapping, mapping }; + +export default mapping; diff --git a/src/utils/request.js b/src/utils/request.js index cd8ca34..142826b 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -1,39 +1,21 @@ import axios from 'axios'; import uuid from '@/utils/uuid'; -import validate from '@/utils/validate'; -import config from '@/config'; import CustomError from './custom-error'; -const { - codeField, - dataField, - msgField, - okCode, - limit = 10, -} = config.request; +const limit = 10; const requestTagMap = {}; /** * axios请求 * @param {object} options 请求参数 - * @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功 * @return {Promise} */ -async function axiosRequest(options, noFormat) { +async function axiosRequest(options) { return axios(options).then((res) => { if (res.status === 200) { - if (noFormat) { - return res; - } - if (validate.isSet(res.data[codeField]) && `${res.data[codeField]}` === `${okCode}`) { - return res.data[dataField]; - } - if (typeof res.data[codeField] !== 'undefined') { - throw new CustomError(res.data[msgField], res.data[codeField]); - } - throw new CustomError('服务器返回内容不规范', -99); + return res; } throw new CustomError(`网络错误${res.status}`, res.status); }); @@ -69,7 +51,6 @@ class NetworkRequest { * @param {string} type 请求的Method * @param {object} headers Header请求参数 * @param {object} data 请求参数 - * @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功 * @param {boolean} defaultContentType 默认的请求方式 * @param {Boolean} priority 优先调用请求 * @@ -85,7 +66,6 @@ class NetworkRequest { type, headers, data, - noFormat = false, defaultContentType = true, requestTag = undefined, responseType, @@ -100,9 +80,7 @@ class NetworkRequest { } return new Promise((resolve, reject) => { - const defaultHeaders = { - ...config.request.headers, - }; + const defaultHeaders = {}; if (defaultContentType === false) { if (NetworkRequest.FormRequest(defaultHeaders)) { defaultHeaders['content-type'] = 'application/json'; @@ -122,7 +100,6 @@ class NetworkRequest { signal: abortController?.signal ?? undefined, responseType, }, - noFormat, (res) => { resolve(res); }, @@ -153,13 +130,13 @@ class NetworkRequest { if (this.tasks.length === 0) { return; } - const [options, onFormat, success, fail, tag] = this.tasks.pop(); + const [options, success, fail, tag] = this.tasks.pop(); // 请求未执行已被中止 if (options?.signal?.aborted) { this.overTask(); return; } - requestTagMap[tag] = axiosRequest(options, onFormat); + requestTagMap[tag] = axiosRequest(options); requestTagMap[tag].finally(() => { this.overTask(); // 一秒内请求不重复 diff --git a/src/views/components/server-detail/server-monitor.vue b/src/views/components/server-detail/server-monitor.vue index d697c78..3388283 100644 --- a/src/views/components/server-detail/server-monitor.vue +++ b/src/views/components/server-detail/server-monitor.vue @@ -131,10 +131,13 @@ function switchPeakShaving() { async function loadMonitor() { await request({ - url: config.nazhua.apiMonitorPath.replace('{id}', props.info.ID), + url: ( + config.nazhua.nezhaVersion === 'v1' ? config.nazhua.v1ApiMonitorPath : config.nazhua.apiMonitorPath + ).replace('{id}', props.info.ID), }).then((res) => { - if (Array.isArray(res)) { - monitorData.value = res; + const list = config.nazhua.nezhaVersion === 'v1' ? res.data?.data : res.data?.result; + if (Array.isArray(list)) { + monitorData.value = list; } }).catch((err) => { console.error(err); diff --git a/src/views/components/server-list/server-list-item-bill.vue b/src/views/components/server-list/server-list-item-bill.vue index 3e05a2b..10d037e 100644 --- a/src/views/components/server-list/server-list-item-bill.vue +++ b/src/views/components/server-list/server-list-item-bill.vue @@ -45,8 +45,10 @@ > {{ billAndPlan.billing.value }} - / - {{ billAndPlan.billing.cycleLabel }} +
{ } } if (validate.isSet(billingDataMod?.amount)) { + let isFree = false; let amountValue = billingDataMod.amount; let label; if (billingDataMod.amount.toString() === '-1') { @@ -71,6 +72,7 @@ export default (params) => { label = `每${cycleLabel}`; } else if (billingDataMod.amount.toString() === '0') { amountValue = config.nazhua.freeAmount || '免费'; + isFree = true; } else { label = `${cycleLabel}付`; } @@ -79,6 +81,7 @@ export default (params) => { value: amountValue, cycleLabel, months, + isFree, }; } // 剩余时间 diff --git a/src/views/composable/server-status.js b/src/views/composable/server-status.js index d6bd2b6..7d8b30b 100644 --- a/src/views/composable/server-status.js +++ b/src/views/composable/server-status.js @@ -63,12 +63,12 @@ export default (params) => { case 'cpu': return { type: 'cpu', - used: (props.info.State.CPU).toFixed(1) * 1, + used: (props.info.State?.CPU || 0).toFixed(1) * 1, colors: { used: '#0088ff', total: 'rgba(255, 255, 255, 0.2)', }, - valText: `${(props.info.State.CPU).toFixed(1) * 1}%`, + valText: `${(props.info.State?.CPU || 0).toFixed(1) * 1}%`, label: 'CPU', content: { default: cpuInfo.value?.core, diff --git a/src/views/home.vue b/src/views/home.vue index 6adc7cb..ed362b3 100644 --- a/src/views/home.vue +++ b/src/views/home.vue @@ -87,35 +87,10 @@ const serverList = computed(() => store.state.serverList); // 服务器总数 const serverCount = computed(() => store.state.serverCount); -/** - * 解构数据 - */ -const serverListData = computed(() => { - const tagMap = {}; - serverList.value.forEach((i) => { - if (i.Tag) { - if (!tagMap[i.Tag]) { - tagMap[i.Tag] = 0; - } - tagMap[i.Tag] += 1; - } - }); - const tags = []; - Object.entries(tagMap).forEach(([tag, count]) => { - tags.push({ - tag, - count, - }); - }); - return { - tags, - }; -}); - -const tagOptions = computed(() => (serverListData.value?.tags || []).map((i) => ({ +const tagOptions = computed(() => store.state.serverGroup.map((i) => ({ key: uuid(), - label: i.tag, - value: i.tag, + label: i.name, + value: i.name, }))); const onlineOptions = computed(() => { diff --git a/src/ws/index.js b/src/ws/index.js index 117e0e8..b6ae878 100644 --- a/src/ws/index.js +++ b/src/ws/index.js @@ -1,10 +1,20 @@ import config from '@/config'; import MessageSubscribe from '@/utils/subscribe'; +import { + handelV1toV0, +} from '@/utils/load-nezha-v1-config'; import WSService from './service'; +function getWsApiPath() { + if (config.nazhua.nezhaVersion === 'v1') { + return config.nazhua.v1WsPath; + } + return config.nazhua.wsPath; +} + const msg = new MessageSubscribe(); const wsService = new WSService({ - wsUrl: config?.nazhua?.wsPath, + wsUrl: getWsApiPath(), onConnect: () => { msg.emit('connect'); }, @@ -16,7 +26,17 @@ const wsService = new WSService({ }, onMessage: (data) => { if (data?.now) { - msg.emit('servers', data); + if (config.nazhua.nezhaVersion === 'v1') { + msg.emit('servers', { + now: data.now, + servers: data?.servers?.map?.((server) => { + const item = handelV1toV0(server); + return item; + }) || [], + }); + } else { + msg.emit('servers', data); + } } else { msg.emit('message', data); } diff --git a/vite.config.js b/vite.config.js index b383feb..cfaecc2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -29,6 +29,11 @@ export default defineConfig({ changeOrigin: true, ws: true, }, + '/api/v1/ws/server': { + target: process.env.WS_HOST, + changeOrigin: true, + ws: true, + }, '/nezha/': { target: process.env.NEZHA_HOST, changeOrigin: true,