From 10e9bf696fe861e1e96c86b960fac53dfdd09dc4 Mon Sep 17 00:00:00 2001 From: Shigma <1700011071@pku.edu.cn> Date: Sun, 7 Mar 2021 17:51:18 +0800 Subject: [PATCH] feat(status): setup websocket server --- build/compile.ts | 2 +- packages/plugin-status/client/app.vue | 18 ++- .../client/components/load-bar.vue | 2 +- packages/plugin-status/package.json | 5 +- packages/plugin-status/server/index.ts | 133 ++---------------- packages/plugin-status/server/profile.ts | 108 ++++++++++++++ packages/plugin-status/server/webui.ts | 76 +++++++--- 7 files changed, 192 insertions(+), 152 deletions(-) create mode 100644 packages/plugin-status/server/profile.ts diff --git a/build/compile.ts b/build/compile.ts index bb83832b58..d4ecd2544d 100644 --- a/build/compile.ts +++ b/build/compile.ts @@ -59,7 +59,7 @@ const KOISHI_VERSION = JSON.stringify(version) entryPoints.push(base + '/src/worker.ts') } else if (name === 'koishi-test-utils') { await tasks[chai] - } else if (name === 'plugin-webui') { + } else if (name === 'plugin-status') { entryPoints.splice(0, 1, base + '/server/index.ts') } diff --git a/packages/plugin-status/client/app.vue b/packages/plugin-status/client/app.vue index 85247c4f1e..897ddc0936 100644 --- a/packages/plugin-status/client/app.vue +++ b/packages/plugin-status/client/app.vue @@ -27,17 +27,15 @@ interface Status { const status = ref(null) -if (import.meta.hot) { - import.meta.hot.on('update', (data) => { - console.log('update', data) - }) -} - onMounted(async () => { - const res = await fetch(KOISHI_SERVER + '/_') - const data = await res.json() - status.value = data - console.log('fetch', data) + const socket = new WebSocket(KOISHI_ENDPOINT) + socket.onmessage = (ev) => { + const data = JSON.parse(ev.data) + console.log('receive', data) + if (data.type === 'update') { + status.value = data.body + } + } }) diff --git a/packages/plugin-status/client/components/load-bar.vue b/packages/plugin-status/client/components/load-bar.vue index 32b50f559d..def0bd0225 100644 --- a/packages/plugin-status/client/components/load-bar.vue +++ b/packages/plugin-status/client/components/load-bar.vue @@ -50,7 +50,7 @@ export default { height: 100%; position: relative; float: left; - transition: 0.3s ease; + transition: 0.6s ease; }; > *:hover { z-index: 10; diff --git a/packages/plugin-status/package.json b/packages/plugin-status/package.json index 586d89fb11..71d157a792 100644 --- a/packages/plugin-status/package.json +++ b/packages/plugin-status/package.json @@ -36,11 +36,14 @@ "koishi-test-utils": "^6.0.0-beta.10" }, "dependencies": { + "@types/ws": "^7.4.0", "@vitejs/plugin-vue": "^1.1.5", "@vue/compiler-sfc": "^3.0.7", "element-plus": "^1.0.2-beta.33", "sass": "^1.32.8", + "systeminformation": "^5.6.1", "vite": "^2.0.5", - "vue": "^3.0.7" + "vue": "^3.0.7", + "ws": "^7.4.3" } } diff --git a/packages/plugin-status/server/index.ts b/packages/plugin-status/server/index.ts index ec40167a19..a962baa3f2 100644 --- a/packages/plugin-status/server/index.ts +++ b/packages/plugin-status/server/index.ts @@ -1,7 +1,8 @@ -import { Context, App, Argv, Bot, Platform } from 'koishi-core' -import { cpus, totalmem, freemem } from 'os' +import { Context, App, Argv } from 'koishi-core' import { interpolate, Time } from 'koishi-utils' import { ActiveData } from './database' +import * as WebUI from './webui' +import Profile from './profile' export * from './database' @@ -10,87 +11,20 @@ declare module 'koishi-core' { counter: number[] } - interface App { - startTime: number - getStatus(): Promise + interface EventMap { + 'status/tick'(): void } } -App.prototype.getStatus = async function (this: App) { - const bots = await Promise.all(this.bots.map(async (bot): Promise => ({ - platform: bot.platform, - selfId: bot.selfId, - username: bot.username, - code: await bot.getStatus(), - rate: bot.counter.slice(1).reduce((prev, curr) => prev + curr, 0), - }))) - const memory = memoryRate() - const cpu: Rate = [appRate, usedRate] - return { bots, memory, cpu, startTime: this.startTime } -} - -export interface Config { - path?: string +export interface Config extends WebUI.Config { refresh?: number format?: string formatBot?: string } -let usage = getCpuUsage() -let appRate: number -let usedRate: number - -function memoryRate(): Rate { - const totalMemory = totalmem() - return [process.memoryUsage().rss / totalMemory, 1 - freemem() / totalMemory] -} - -function getCpuUsage() { - let totalIdle = 0, totalTick = 0 - const cpuInfo = cpus() - const usage = process.cpuUsage().user - - for (const cpu of cpuInfo) { - for (const type in cpu.times) { - totalTick += cpu.times[type] - } - totalIdle += cpu.times.idle - } - - return { - app: usage / 1000, - used: (totalTick - totalIdle) / cpuInfo.length, - total: totalTick / cpuInfo.length, - } -} - -function updateCpuUsage() { - const newUsage = getCpuUsage() - const totalDifference = newUsage.total - usage.total - appRate = (newUsage.app - usage.app) / totalDifference - usedRate = (newUsage.used - usage.used) / totalDifference - usage = newUsage -} - -export type Rate = [app: number, total: number] - -export interface BaseStatus { - bots: BotStatus[] - memory: Rate - cpu: Rate - startTime: number -} - -export interface Status extends BaseStatus, ActiveData { +export interface Status extends Profile, ActiveData { timestamp: number -} - -export interface BotStatus { - username?: string - selfId: string - platform: Platform - code: Bot.Status - rate?: number + startTime: number } type StatusCallback = (this: App, status: Status, config: Config) => void | Promise @@ -106,7 +40,6 @@ extend(async function (status) { }) const defaultConfig: Config = { - path: '/status', refresh: Time.minute, // eslint-disable-next-line no-template-curly-in-string formatBot: '{{ username }}:{{ code ? `无法连接` : `工作中(${rate}/min)` }}', @@ -115,59 +48,20 @@ const defaultConfig: Config = { '==========', '活跃用户数量:{{ activeUsers }}', '活跃群数量:{{ activeGroups }}', - '启动时间:{{ new Date(startTime).toLocaleString("zh-CN", { hour12: false }) }}', - 'CPU 使用率:{{ (cpu.app * 100).toFixed() }}% / {{ (cpu.total * 100).toFixed() }}%', - '内存使用率:{{ (memory.app * 100).toFixed() }}% / {{ (memory.total * 100).toFixed() }}%', + 'CPU 使用率:{{ (cpu[0] * 100).toFixed() }}% / {{ (cpu[1] * 100).toFixed() }}%', + '内存使用率:{{ (memory[0] * 100).toFixed() }}% / {{ (memory[1] * 100).toFixed() }}%', ].join('\n'), } export const name = 'status' export function apply(ctx: Context, config: Config = {}) { - const all = ctx.all() const { refresh, formatBot, format } = { ...defaultConfig, ...config } - all.on('command', ({ session }: Argv<'lastCall'>) => { + ctx.all().on('command', ({ session }: Argv<'lastCall'>) => { session.user.lastCall = new Date() }) - all.before('send', (session) => { - session.bot.counter[0] += 1 - }) - - let timer: NodeJS.Timeout - ctx.on('connect', async () => { - ctx.app.startTime = Date.now() - - ctx.bots.forEach((bot) => { - bot.counter = new Array(61).fill(0) - }) - - timer = setInterval(() => { - updateCpuUsage() - ctx.bots.forEach(({ counter }) => { - counter.unshift(0) - counter.splice(-1, 1) - }) - }, 1000) - - if (!ctx.router) return - ctx.router.get('/status', async (ctx) => { - const status = await getStatus().catch((error) => { - all.logger('status').warn(error) - return null - }) - if (!status) return ctx.status = 500 - ctx.set('Content-Type', 'application/json') - ctx.set('Access-Control-Allow-Origin', '*') - ctx.body = status - }) - }) - - ctx.before('disconnect', () => { - clearInterval(timer) - }) - ctx.command('status', '查看机器人运行状态') .shortcut('你的状态', { prefix: true }) .shortcut('你的状况', { prefix: true }) @@ -190,7 +84,7 @@ export function apply(ctx: Context, config: Config = {}) { }) async function _getStatus() { - const status = await ctx.app.getStatus() as Status + const status = await Profile.from(ctx) as Status await Promise.all(callbacks.map(callback => callback.call(ctx.app, status, config))) status.timestamp = timestamp return status @@ -205,4 +99,7 @@ export function apply(ctx: Context, config: Config = {}) { timestamp = now return cachedStatus = _getStatus() } + + ctx.plugin(Profile) + if (config.port) ctx.plugin(WebUI, config) } diff --git a/packages/plugin-status/server/profile.ts b/packages/plugin-status/server/profile.ts new file mode 100644 index 0000000000..5678d80957 --- /dev/null +++ b/packages/plugin-status/server/profile.ts @@ -0,0 +1,108 @@ +import { Bot, Context, Platform } from 'koishi-core' +import { Time } from 'koishi-utils' +import { cpus } from 'os' +import { mem } from 'systeminformation' + +export type Rate = [app: number, total: number] + +let usage = getCpuUsage() +let appRate: number +let usedRate: number + +async function memoryRate(): Promise { + const { total, active } = await mem() + return [process.memoryUsage().rss / total, active / total] +} + +function getCpuUsage() { + let totalIdle = 0, totalTick = 0 + const cpuInfo = cpus() + const usage = process.cpuUsage().user + + for (const cpu of cpuInfo) { + for (const type in cpu.times) { + totalTick += cpu.times[type] + } + totalIdle += cpu.times.idle + } + + return { + // microsecond values + app: usage / 1000, + // use total value (do not know how to get the cpu on which the koishi is running) + used: (totalTick - totalIdle) / cpuInfo.length, + total: totalTick / cpuInfo.length, + } +} + +function updateCpuUsage() { + const newUsage = getCpuUsage() + const totalDifference = newUsage.total - usage.total + appRate = (newUsage.app - usage.app) / totalDifference + usedRate = (newUsage.used - usage.used) / totalDifference + usage = newUsage +} + +export interface BotData { + username?: string + selfId: string + platform: Platform + code: Bot.Status + rate?: number +} + +export namespace BotData { + export const from = async (bot: Bot) => ({ + platform: bot.platform, + selfId: bot.selfId, + username: bot.username, + code: await bot.getStatus(), + rate: bot.counter.slice(1).reduce((prev, curr) => prev + curr, 0), + } as BotData) +} + +export interface Profile { + bots: BotData[] + memory: Rate + cpu: Rate +} + +export namespace Profile { + export interface Config { + tick?: number + } + + export async function from(ctx: Context) { + const [memory, bots] = await Promise.all([ + memoryRate(), + Promise.all(ctx.bots.map(BotData.from)), + ]) + const cpu: Rate = [appRate, usedRate] + return { bots, memory, cpu } as Profile + } + + export function apply(ctx: Context, config: Config = {}) { + const { tick = 5 * Time.second } = config + + ctx.all().before('send', (session) => { + session.bot.counter[0] += 1 + }) + + ctx.on('connect', async () => { + ctx.bots.forEach((bot) => { + bot.counter = new Array(61).fill(0) + }) + + ctx.setInterval(() => { + updateCpuUsage() + ctx.bots.forEach(({ counter }) => { + counter.unshift(0) + counter.splice(-1, 1) + }) + ctx.emit('status/tick') + }, tick) + }) + } +} + +export default Profile diff --git a/packages/plugin-status/server/webui.ts b/packages/plugin-status/server/webui.ts index 4339812ed4..125dd59a01 100644 --- a/packages/plugin-status/server/webui.ts +++ b/packages/plugin-status/server/webui.ts @@ -2,17 +2,14 @@ import { Context, Plugin } from 'koishi-core' import { assertProperty } from 'koishi-utils' import { resolve } from 'path' import { createServer, ViteDevServer } from 'vite' +import WebSocket from 'ws' import vuePlugin from '@vitejs/plugin-vue' - -declare module 'koishi-core' { - interface App { - vite: ViteDevServer - } -} +import Profile from './profile' export interface Config { + path?: string port?: number - server?: string + selfUrl?: string } export interface PluginData extends Plugin.Meta { @@ -24,19 +21,35 @@ export const name = 'webui' export function apply(ctx: Context, config: Config = {}) { const koishiPort = assertProperty(ctx.app.options, 'port') - const { port = 8080, server = `http://localhost:${koishiPort}` } = config + const { path = '/status', port = 8080, selfUrl = `ws://localhost:${koishiPort}` } = config + let vite: ViteDevServer + let wsServer: WebSocket.Server ctx.on('connect', async () => { - const vite = await createServer({ + vite = await createServer({ root: resolve(__dirname, '../client'), plugins: [vuePlugin()], define: { - KOISHI_SERVER: JSON.stringify(server), + KOISHI_ENDPOINT: JSON.stringify(selfUrl + path), }, }) + wsServer = new WebSocket.Server({ + path, + server: ctx.app._httpServer, + }) + + wsServer.on('connection', async (socket) => { + if (!plugins) updatePlugins() + if (!profile) await updateProfile() + const data = JSON.stringify({ + type: 'update', + body: { ...profile, plugins }, + }) + socket.send(data) + }) + await vite.listen(port) - ctx.app.vite = vite }) function* getDeps(state: Plugin.State): Generator { @@ -58,20 +71,41 @@ export function apply(ctx: Context, config: Config = {}) { return [{ name, sideEffect, children, dependencies }] } - ctx.router.get('/plugins', (ctx) => { - ctx.set('access-control-allow-origin', '*') - ctx.body = traverse(null) - }) + let plugins: PluginData[] + let profile: Profile - ctx.on('registry', () => { - ctx.app.vite?.ws.send({ - type: 'custom', - event: 'registry-update', - data: traverse(null), + async function broadcast(callback: () => void | Promise) { + if (!wsServer?.clients.size) return + await callback() + const data = JSON.stringify({ + type: 'update', + body: { ...profile, plugins }, }) + wsServer.clients.forEach((socket) => socket.send(data)) + } + + function updatePlugins() { + plugins = traverse(null) + } + + async function updateProfile() { + profile = await Profile.from(ctx) + } + + ctx.on('registry', () => { + broadcast(updatePlugins) + }) + + ctx.on('status/tick', () => { + broadcast(updateProfile) }) ctx.before('disconnect', async () => { - await ctx.app.vite?.close() + await Promise.all([ + vite?.close(), + new Promise((resolve, reject) => { + wsServer.close((err) => err ? resolve() : reject(err)) + }), + ]) }) }