From c416c1893e046f26084df7b38fa36bfe8aebcde0 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 29 Oct 2025 02:57:27 +0800 Subject: [PATCH] core: add `hydrooj diag` --- packages/hydrooj/bin/commands.ts | 1 + packages/hydrooj/package.json | 4 +- packages/hydrooj/src/commands/diagnosis.ts | 135 +++++++++++++++++++++ packages/hydrooj/src/commands/install.ts | 37 +++--- 4 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 packages/hydrooj/src/commands/diagnosis.ts diff --git a/packages/hydrooj/bin/commands.ts b/packages/hydrooj/bin/commands.ts index 947c9df0d..2ac3c6cce 100644 --- a/packages/hydrooj/bin/commands.ts +++ b/packages/hydrooj/bin/commands.ts @@ -15,6 +15,7 @@ if (!argv.args[0] || argv.args[0] === 'cli') { require('../src/commands/addon').register(cli); require('../src/commands/db').register(cli); require('../src/commands/patch').register(cli); + require('../src/commands/diagnosis').register(cli); cli.help(); cli.parse(); if (!cli.matchedCommand) { diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 65bb25446..162fdc614 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -41,7 +41,7 @@ "mongodb": "^6.20.0", "mongodb-uri": "^0.9.7", "nanoid": "^5.1.6", - "nodemailer": "^7.0.9", + "nodemailer": "^7.0.10", "notp": "^2.0.3", "p-queue": "^9.0.0", "sanitize-filename": "^1.6.3", @@ -63,7 +63,7 @@ "@types/lodash": "^4.17.20", "@types/mime-types": "^3.0.1", "@types/mongodb-uri": "^0.9.4", - "@types/nodemailer": "^7.0.2", + "@types/nodemailer": "^7.0.3", "@types/notp": "^2.0.5", "@types/saslprep": "^1.0.3", "@types/semver": "^7.7.1", diff --git a/packages/hydrooj/src/commands/diagnosis.ts b/packages/hydrooj/src/commands/diagnosis.ts new file mode 100644 index 000000000..bcdba1604 --- /dev/null +++ b/packages/hydrooj/src/commands/diagnosis.ts @@ -0,0 +1,135 @@ +import child, { execSync } from 'child_process'; +import dns from 'dns/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import { CAC, cac } from 'cac'; +import fs from 'fs-extra'; +import yaml from 'js-yaml'; +import superagent from 'superagent'; +import { formatSeconds, Logger } from '@hydrooj/utils'; +import * as sysinfo from '@hydrooj/utils/lib/sysinfo'; +import { getAddons } from '../options'; + +const logger = new Logger('diagnosis'); +const argv = cac().parse(); + +export function register(cli: CAC) { + let baseInfo: sysinfo.StatusFull; + let endpoints: string[] = ['hydro.ac']; + + const padEnd = (str: string, length: number) => { + return str.length >= length ? `${str} ` : str.toString().padEnd(length, ' '); + }; + + const collectors: Record [any, any] | Promise<[any, any]>> = { + OS: () => { + const info = fs.readFileSync('/etc/os-release', 'utf-8'); + const prettyName = info.split('\n').find((i) => i.startsWith('PRETTY_NAME=')).split('=')[1].trim() + || `${baseInfo.osinfo.distro} ${baseInfo.osinfo.release} ${baseInfo.osinfo.arch}`; + return [`${prettyName} ${baseInfo.osinfo.kernel}`, info]; + }, + CPU: () => { + const cpuinfo = fs.readFileSync('/proc/cpuinfo', 'utf-8'); + const modelName = cpuinfo.split('\n').find((i) => i.startsWith('model name')).split(':')[1].trim(); + return [ + modelName, + cpuinfo.split('\n'), + ]; + }, + Memory: () => { + const total = Math.floor(baseInfo.memory.total / 1024 / 1024); + const used = Math.floor((baseInfo.memory.total - baseInfo.memory.available) / 1024 / 1024); + const info = `${used}/${total}MB`; + let dmidecode = ''; + try { + const result = child.spawnSync('dmidecode', ['-t', 'memory']); + dmidecode = `${result.stdout.toString()}\n${result.stderr.toString()}`.trim(); + } catch (e) { + dmidecode = e.message; + } + return [info, `${fs.readFileSync('/proc/meminfo', 'utf-8')}\n${dmidecode}`.split('\n')]; + }, + NodeJS: () => { + const info = `${process.versions.node} (${process.execPath})`; + return [info, info]; + }, + Network: async () => { + const ips = await dns.resolve4('hydro.ac'); + const info = [`hydro.ac: ${ips.join(', ')}`]; + try { + const res = await superagent.get('https://hydro.ac/hydroac-client-version'); + info.push(`connect: ${res.status} ${!!res.body.version}`); + if (res.body.endpoints?.length) endpoints = res.body.endpoints; + } catch (e) { } + return [info.join('\n'), info.join('\n')]; + }, + PM2: async () => { + const result = child.spawnSync('pm2', ['jlist']); + if (result.stderr?.toString()?.trim()) { + return [result.stderr.toString(), result.stderr.toString()]; + } + const json = JSON.parse(result.stdout.toString()); + const processes = json.map((i) => `PID=${padEnd(i.pid || 0, 7)}${padEnd(i.name, 8)}\ +${padEnd(formatSeconds(Math.floor((Date.now() - i.pm2_env.pm_uptime) / 1000), false), 8)}\ +(${i.pm2_env.pm_exec_path} ${(i.pm2_env.args || []).join(' ')})`); + return [processes, processes]; + }, + HydroOJ: () => { + const info = `${require('hydrooj/package.json').version}${process.env.HYDRO_PROFILE ? ` (${process.env.HYDRO_PROFILE})` : ''}`; + const addons = getAddons(); + const addonsInfo = Object.fromEntries(addons.map((i) => { + try { + const pkgJson = require(`${i}/package.json`); + return [i, pkgJson.version]; + } catch (e) { + return [i, e.message]; + } + })); + return [ + { Hydro: info, ...addonsInfo }, { Hydro: info, ...addonsInfo }, + ]; + }, + Logs: () => { + const logs = execSync('pm2 logs --lines 1000 --nostream', { maxBuffer: 100 * 1024 * 1024 }); + return ['Omitted, view in detail mode', logs.toString().split('\n')]; + }, + }; + + const command = async () => { + logger.info('Collecting system info'); + const infos = {}; + const infoDetails = {}; + baseInfo = await sysinfo.get(); + await Promise.all(Object.entries(collectors).map(async ([key, value]) => { + try { + const [info, detail] = await value(); + infos[key] = argv.options.detail ? detail : info; + infoDetails[key] = detail; + } catch (e) { + infos[key] = e.message; + infoDetails[key] = e.message; + } + })); + logger.info('System info:'); + console.log(yaml.dump(infos, { noCompatMode: true, noArrayIndent: true, lineWidth: process.stdout.columns || 80 })); + logger.info('Uploading detail to pastebin...'); + const tmpFile = path.join(tmpdir(), 'report.yaml'); + fs.writeFileSync(tmpFile, yaml.dump(infoDetails, { noArrayIndent: true, lineWidth: 150 })); + if (process.env.VSCODE_INJECTION) { + try { + child.exec(`code ${tmpFile}`); + } catch (e) { } + try { + child.exec(`cursor ${tmpFile}`); + } catch (err) { } + } + const res = await superagent.post(`https://${endpoints[0]}/paste?code=dm`).attach('file', tmpFile); + const [url, , ...rest] = res.text.split('\n'); + logger.info(`Info created on ${url}`); + logger.info('Check logs content before sharing this url.'); + console.log(rest.join('\n')); + }; + + cli.command('diag').option('-d', 'detail').action(command); + cli.command('diagnosis').option('-d', 'detail').action(command); +} diff --git a/packages/hydrooj/src/commands/install.ts b/packages/hydrooj/src/commands/install.ts index 30bd3ab5b..690f4befb 100644 --- a/packages/hydrooj/src/commands/install.ts +++ b/packages/hydrooj/src/commands/install.ts @@ -34,7 +34,13 @@ function downloadAndExtractTgz(url: string, dest: string) { }); } async function downloadAndExtractZip(url: string, dest: string) { - const res = await superagent.get(url).set('User-Agent', userAgent).responseType('arraybuffer'); + let res; + try { + res = await superagent.get(url).set('User-Agent', userAgent).responseType('arraybuffer'); + } catch (e) { + logger.error('Failed to download', url, e.message); + return; + } await extractZip(new ZipReader(new BlobReader(new Blob([res.body]))), dest, { strip: true, overwrite: true }); } const types = { @@ -57,27 +63,22 @@ export function register(cli: CAC) { try { src = child.execSync(`yarn info ${src} dist.tarball`, { cwd: os.tmpdir() }) .toString().trim().split('\n')[1]; + if (!src.startsWith('http')) throw new Error(); } catch (e) { throw new Error('Cannot fetch package info.'); } } - if (src.startsWith('@hydrooj/')) { - src = child.execSync(`npm info ${src} dist.tarball`, { cwd: os.tmpdir() }).toString().trim(); - if (!src.startsWith('http')) throw new Error('Cannot fetch package info.'); - } - if (src.startsWith('http')) { - const url = new URL(src); - const filename = url.pathname.split('/').pop()!; - if (Object.keys(types).find((i) => filename.endsWith(i))) { - const name = filename.replace(/(-?(\d+\.\d+\.\d+|latest))?(\.tar\.gz|\.zip|\.tgz)$/g, ''); - newAddonPath = path.join(addonDir, name); - logger.info(`Downloading ${src} to ${newAddonPath}`); - fs.ensureDirSync(newAddonPath); - fs.emptyDirSync(newAddonPath); - const func = types[Object.keys(types).find((i) => filename.endsWith(i))]!; - await func(src, newAddonPath); - } else throw new Error('Unsupported file type'); - } else throw new Error(`Unsupported install source: ${src}`); + const url = new URL(src); + const filename = url.pathname.split('/').pop()!; + if (Object.keys(types).find((i) => filename.endsWith(i))) { + const name = filename.replace(/(-?(\d+\.\d+\.\d+|latest))?(\.tar\.gz|\.zip|\.tgz)$/g, ''); + newAddonPath = path.join(addonDir, name); + logger.info(`Downloading ${src} to ${newAddonPath}`); + fs.ensureDirSync(newAddonPath); + fs.emptyDirSync(newAddonPath); + const func = types[Object.keys(types).find((i) => filename.endsWith(i))]!; + await func(src, newAddonPath); + } else throw new Error('Unsupported file type'); if (!newAddonPath) throw new Error('Addon download failed'); logger.info('Installing depedencies'); if (!fs.existsSync(path.join(newAddonPath, 'package.json'))) throw new Error('Invalid plugin file');