💥 兼容哪吒V1的数据接口

This commit is contained in:
hi2hi 2024-12-06 05:38:12 +00:00
parent cc585221c1
commit 1c6107cb07
17 changed files with 379 additions and 74 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
demo
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@ -1,3 +1,8 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server { server {
listen 80; listen 80;
server_name ${DOMAIN}; server_name ${DOMAIN};
@ -7,7 +12,18 @@ server {
proxy_pass ${NEZHA}ws; proxy_pass ${NEZHA}ws;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -16,9 +16,13 @@ window.$$nazhuaConfig = {
// hideFilter: false, // 隐藏筛选 // hideFilter: false, // 隐藏筛选
// hideTag: false, // 隐藏标签 // hideTag: false, // 隐藏标签
// customCodeMap: {}, // 自定义的地图点信息 // customCodeMap: {}, // 自定义的地图点信息
// nezhaVersion: 'v1', // 哪吒版本
// apiMonitorPath: '/api/v1/monitor/{id}', // apiMonitorPath: '/api/v1/monitor/{id}',
// wsPath: '/ws', // wsPath: '/ws',
// nezhaPath: '/nezha/', // nezhaPath: '/nezha/',
// nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型 // nezhaV0ConfigType: 'servers', // 哪吒v0数据读取类型
// v1ApiMonitorPath: '/api/v1/service/{id}',
// v1WsPath: '/api/v1/ws/server',
// v1GroupPath: '/api/v1/server-group',
// routeMode: 'h5', // 路由模式 // routeMode: 'h5', // 路由模式
}; };

View File

@ -55,7 +55,7 @@ async function wsReconnect() {
onMounted(async () => { onMounted(async () => {
handleSystem(); handleSystem();
refreshTime(); refreshTime();
await store.dispatch('loadServers'); await store.dispatch('initServerInfo');
msg.on('close', () => { msg.on('close', () => {
console.log('ws closed'); console.log('ws closed');
wsReconnect(); wsReconnect();

View File

@ -12,10 +12,14 @@ const config = {
}, },
nazhua: { nazhua: {
title: '哪吒监控', title: '哪吒监控',
nezhaVersion: 'v0',
apiMonitorPath: '/api/v1/monitor/{id}', apiMonitorPath: '/api/v1/monitor/{id}',
wsPath: '/ws', wsPath: '/ws',
nezhaPath: '/nezha/', nezhaPath: '/nezha/',
nezhaV0ConfigType: 'servers', nezhaV0ConfigType: 'servers',
v1ApiMonitorPath: '/api/v1/service/{id}',
v1WsPath: '/api/v1/ws/server',
v1GroupPath: '/api/v1/server-group',
// 解构载入自定义配置 // 解构载入自定义配置
...(window.$$nazhuaConfig || {}), ...(window.$$nazhuaConfig || {}),
}, },

View File

@ -2,7 +2,13 @@ import {
createStore, createStore,
} from 'vuex'; } from 'vuex';
import dayjs from 'dayjs'; 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 { import {
msg, msg,
@ -10,6 +16,7 @@ import {
const defaultState = () => ({ const defaultState = () => ({
init: false, init: false,
serverGroup: [],
serverList: [], serverList: [],
serverCount: { serverCount: {
total: 0, total: 0,
@ -35,9 +42,13 @@ function handleServerCount(servers) {
return counts; return counts;
} }
let firstSetServers = true;
const store = createStore({ const store = createStore({
state: defaultState(), state: defaultState(),
mutations: { mutations: {
SET_SERVER_GROUP(state, serverGroup) {
state.serverGroup = serverGroup;
},
SET_SERVERS(state, servers) { SET_SERVERS(state, servers) {
const newServers = [...servers]; const newServers = [...servers];
newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex); newServers.sort((a, b) => b.DisplayIndex - a.DisplayIndex);
@ -58,8 +69,6 @@ const store = createStore({
}; };
if (oldItem?.PublicNote) { if (oldItem?.PublicNote) {
serverItem.PublicNote = oldItem.PublicNote; serverItem.PublicNote = oldItem.PublicNote;
} else {
serverItem.PublicNote = {};
} }
return serverItem; return serverItem;
}); });
@ -74,8 +83,20 @@ const store = createStore({
/** /**
* 加载服务器列表 * 加载服务器列表
*/ */
async loadServers({ commit }) { async initServerInfo({ commit }) {
const serverResult = await loadNezhaConfig(); 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) { if (!serverResult) {
console.error('load server config failed'); console.error('load server config failed');
return; return;
@ -87,6 +108,11 @@ const store = createStore({
}; };
return item; return item;
}) || []; }) || [];
const res = loadNezhaV0ServerGroup(servers);
if (res) {
commit('SET_SERVER_GROUP', res);
}
firstSetServers = false;
commit('SET_SERVERS', servers); commit('SET_SERVERS', servers);
}, },
/** /**
@ -104,7 +130,12 @@ const store = createStore({
}; };
return item; return item;
}) || []; }) || [];
commit('UPDATE_SERVERS', servers); if (firstSetServers) {
firstSetServers = false;
commit('SET_SERVERS', servers);
} else {
commit('UPDATE_SERVERS', servers);
}
} }
}); });
}, },

View File

@ -38,3 +38,30 @@ export default async () => fetch(config.nazhua.nezhaPath).then((res) => res.text
} }
return null; return null;
}).catch(() => 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;
};

View File

@ -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);

View File

@ -0,0 +1,99 @@
/**
* 对象映射封装
*/
class Mapping {
/**
* 字符串映射对象
*
* @param {Record<string, any>} 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<string, any> | 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<string, unknown>} data 数据对象
*
* @return {Record<string, unknown>}
*/
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;

View File

@ -1,39 +1,21 @@
import axios from 'axios'; import axios from 'axios';
import uuid from '@/utils/uuid'; import uuid from '@/utils/uuid';
import validate from '@/utils/validate';
import config from '@/config';
import CustomError from './custom-error'; import CustomError from './custom-error';
const { const limit = 10;
codeField,
dataField,
msgField,
okCode,
limit = 10,
} = config.request;
const requestTagMap = {}; const requestTagMap = {};
/** /**
* axios请求 * axios请求
* @param {object} options 请求参数 * @param {object} options 请求参数
* @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功
* @return {Promise} * @return {Promise}
*/ */
async function axiosRequest(options, noFormat) { async function axiosRequest(options) {
return axios(options).then((res) => { return axios(options).then((res) => {
if (res.status === 200) { if (res.status === 200) {
if (noFormat) { return res;
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);
} }
throw new CustomError(`网络错误${res.status}`, res.status); throw new CustomError(`网络错误${res.status}`, res.status);
}); });
@ -69,7 +51,6 @@ class NetworkRequest {
* @param {string} type 请求的Method * @param {string} type 请求的Method
* @param {object} headers Header请求参数 * @param {object} headers Header请求参数
* @param {object} data 请求参数 * @param {object} data 请求参数
* @param {boolean} noFormat 不进行返回数据的格式化处理 网络状态200即为成功
* @param {boolean} defaultContentType 默认的请求方式 * @param {boolean} defaultContentType 默认的请求方式
* @param {Boolean} priority 优先调用请求 * @param {Boolean} priority 优先调用请求
* *
@ -85,7 +66,6 @@ class NetworkRequest {
type, type,
headers, headers,
data, data,
noFormat = false,
defaultContentType = true, defaultContentType = true,
requestTag = undefined, requestTag = undefined,
responseType, responseType,
@ -100,9 +80,7 @@ class NetworkRequest {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const defaultHeaders = { const defaultHeaders = {};
...config.request.headers,
};
if (defaultContentType === false) { if (defaultContentType === false) {
if (NetworkRequest.FormRequest(defaultHeaders)) { if (NetworkRequest.FormRequest(defaultHeaders)) {
defaultHeaders['content-type'] = 'application/json'; defaultHeaders['content-type'] = 'application/json';
@ -122,7 +100,6 @@ class NetworkRequest {
signal: abortController?.signal ?? undefined, signal: abortController?.signal ?? undefined,
responseType, responseType,
}, },
noFormat,
(res) => { (res) => {
resolve(res); resolve(res);
}, },
@ -153,13 +130,13 @@ class NetworkRequest {
if (this.tasks.length === 0) { if (this.tasks.length === 0) {
return; return;
} }
const [options, onFormat, success, fail, tag] = this.tasks.pop(); const [options, success, fail, tag] = this.tasks.pop();
// 请求未执行已被中止 // 请求未执行已被中止
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
this.overTask(); this.overTask();
return; return;
} }
requestTagMap[tag] = axiosRequest(options, onFormat); requestTagMap[tag] = axiosRequest(options);
requestTagMap[tag].finally(() => { requestTagMap[tag].finally(() => {
this.overTask(); this.overTask();
// 一秒内请求不重复 // 一秒内请求不重复

View File

@ -131,10 +131,13 @@ function switchPeakShaving() {
async function loadMonitor() { async function loadMonitor() {
await request({ 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) => { }).then((res) => {
if (Array.isArray(res)) { const list = config.nazhua.nezhaVersion === 'v1' ? res.data?.data : res.data?.result;
monitorData.value = res; if (Array.isArray(list)) {
monitorData.value = list;
} }
}).catch((err) => { }).catch((err) => {
console.error(err); console.error(err);

View File

@ -45,8 +45,10 @@
> >
<span class="text"> <span class="text">
<span class="text-item value-text">{{ billAndPlan.billing.value }}</span> <span class="text-item value-text">{{ billAndPlan.billing.value }}</span>
<span class="text-item">/</span> <template v-if="!billAndPlan.billing.isFree">
<span class="text-item label-text">{{ billAndPlan.billing.cycleLabel }}</span> <span class="text-item">/</span>
<span class="text-item label-text">{{ billAndPlan.billing.cycleLabel }}</span>
</template>
</span> </span>
</div> </div>
<div <div

View File

@ -64,6 +64,7 @@ export default (params) => {
} }
} }
if (validate.isSet(billingDataMod?.amount)) { if (validate.isSet(billingDataMod?.amount)) {
let isFree = false;
let amountValue = billingDataMod.amount; let amountValue = billingDataMod.amount;
let label; let label;
if (billingDataMod.amount.toString() === '-1') { if (billingDataMod.amount.toString() === '-1') {
@ -71,6 +72,7 @@ export default (params) => {
label = `${cycleLabel}`; label = `${cycleLabel}`;
} else if (billingDataMod.amount.toString() === '0') { } else if (billingDataMod.amount.toString() === '0') {
amountValue = config.nazhua.freeAmount || '免费'; amountValue = config.nazhua.freeAmount || '免费';
isFree = true;
} else { } else {
label = `${cycleLabel}`; label = `${cycleLabel}`;
} }
@ -79,6 +81,7 @@ export default (params) => {
value: amountValue, value: amountValue,
cycleLabel, cycleLabel,
months, months,
isFree,
}; };
} }
// 剩余时间 // 剩余时间

View File

@ -63,12 +63,12 @@ export default (params) => {
case 'cpu': case 'cpu':
return { return {
type: 'cpu', type: 'cpu',
used: (props.info.State.CPU).toFixed(1) * 1, used: (props.info.State?.CPU || 0).toFixed(1) * 1,
colors: { colors: {
used: '#0088ff', used: '#0088ff',
total: 'rgba(255, 255, 255, 0.2)', 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', label: 'CPU',
content: { content: {
default: cpuInfo.value?.core, default: cpuInfo.value?.core,

View File

@ -87,35 +87,10 @@ const serverList = computed(() => store.state.serverList);
// //
const serverCount = computed(() => store.state.serverCount); const serverCount = computed(() => store.state.serverCount);
/** const tagOptions = computed(() => store.state.serverGroup.map((i) => ({
* 解构数据
*/
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) => ({
key: uuid(), key: uuid(),
label: i.tag, label: i.name,
value: i.tag, value: i.name,
}))); })));
const onlineOptions = computed(() => { const onlineOptions = computed(() => {

View File

@ -1,10 +1,20 @@
import config from '@/config'; import config from '@/config';
import MessageSubscribe from '@/utils/subscribe'; import MessageSubscribe from '@/utils/subscribe';
import {
handelV1toV0,
} from '@/utils/load-nezha-v1-config';
import WSService from './service'; import WSService from './service';
function getWsApiPath() {
if (config.nazhua.nezhaVersion === 'v1') {
return config.nazhua.v1WsPath;
}
return config.nazhua.wsPath;
}
const msg = new MessageSubscribe(); const msg = new MessageSubscribe();
const wsService = new WSService({ const wsService = new WSService({
wsUrl: config?.nazhua?.wsPath, wsUrl: getWsApiPath(),
onConnect: () => { onConnect: () => {
msg.emit('connect'); msg.emit('connect');
}, },
@ -16,7 +26,17 @@ const wsService = new WSService({
}, },
onMessage: (data) => { onMessage: (data) => {
if (data?.now) { 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 { } else {
msg.emit('message', data); msg.emit('message', data);
} }

View File

@ -29,6 +29,11 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },
'/api/v1/ws/server': {
target: process.env.WS_HOST,
changeOrigin: true,
ws: true,
},
'/nezha/': { '/nezha/': {
target: process.env.NEZHA_HOST, target: process.env.NEZHA_HOST,
changeOrigin: true, changeOrigin: true,