This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* kit glance 命令
|
||||
* - 记录模式:--pid <pid> 或 --port <port>,采集一次写入 sqlite(~/.kit/glance.db)
|
||||
* - 报告模式:--report [报告文件](默认 report.html),需配合 --pid/--port
|
||||
* - --startTime/--endTime 格式 yyyyMMddHHmmss,指定报告时间范围
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const store = require("./store");
|
||||
const report = require("./report");
|
||||
|
||||
const monitor = require("./monitor");
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS_24 = 24 * HOUR;
|
||||
|
||||
function parseTime(text) {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(text);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function resolveRange(startText, endText) {
|
||||
if (startText && endText) {
|
||||
const s = parseTime(startText);
|
||||
const e = parseTime(endText);
|
||||
if (s == null || e == null) {
|
||||
return { error: "--startTime/--endTime 格式应为 yyyyMMddHHmmss" };
|
||||
}
|
||||
if (e <= s) {
|
||||
return { error: "--endTime 必须晚于 --startTime" };
|
||||
}
|
||||
return { start: s, end: e };
|
||||
}
|
||||
const now = Date.now();
|
||||
if (startText) {
|
||||
const s = parseTime(startText);
|
||||
if (s == null) {
|
||||
return { error: "--startTime 格式应为 yyyyMMddHHmmss" };
|
||||
}
|
||||
return { start: s, end: s + HOURS_24 };
|
||||
}
|
||||
if (endText) {
|
||||
const e = parseTime(endText);
|
||||
if (e == null) {
|
||||
return { error: "--endTime 格式应为 yyyyMMddHHmmss" };
|
||||
}
|
||||
return { start: e - HOURS_24, end: e };
|
||||
}
|
||||
return { start: now - HOURS_24, end: now };
|
||||
}
|
||||
|
||||
async function record(cli) {
|
||||
const db = store.open();
|
||||
let record = null;
|
||||
|
||||
const pidText = cli.getParamValue("pid");
|
||||
if (pidText && pidText !== "") {
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
$logger.error(`pid 参数无效: ${pidText}`);
|
||||
process.exit(-1);
|
||||
}
|
||||
const data = await monitor.collectPid(pid);
|
||||
if (!data) {
|
||||
$logger.warn(`进程 ${pid} 不存在或已退出,本次不记录`);
|
||||
return;
|
||||
}
|
||||
record = { ts: Date.now(), target: "pid", pid, port: null, cpu: data.cpu, mem: data.mem, handle: data.handle };
|
||||
} else {
|
||||
const portText = cli.getParamValue("port");
|
||||
const port = Number(portText);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
$logger.error(`port 参数无效: ${portText}`);
|
||||
process.exit(-1);
|
||||
}
|
||||
const found = monitor.findPortPid(port);
|
||||
if (!found) {
|
||||
$logger.warn(`端口 ${port} 无监听进程,本次不记录`);
|
||||
return;
|
||||
}
|
||||
const data = await monitor.collectPid(found);
|
||||
if (!data) {
|
||||
$logger.warn(`进程 ${found} 已退出,本次不记录`);
|
||||
return;
|
||||
}
|
||||
record = { ts: Date.now(), target: "port", pid: found, port, cpu: data.cpu, mem: data.mem, handle: data.handle };
|
||||
}
|
||||
|
||||
store.insert(db, record);
|
||||
$logger.info(`已记录 target=${record.target} pid=${record.pid} cpu=${record.cpu}% mem=${record.mem}MB handle=${record.handle}`);
|
||||
}
|
||||
|
||||
function buildReport(cli, reportFile) {
|
||||
const isPid = cli.getParamValue("pid") != null;
|
||||
const { start, end, error } = resolveRange(cli.getParamValue("startTime"), cli.getParamValue("endTime"));
|
||||
if (error) {
|
||||
$logger.error(error);
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
const db = store.open();
|
||||
const rows = isPid
|
||||
? store.queryRange(db, { pid: Number(cli.getParamValue("pid")), start, end })
|
||||
: store.queryRange(db, { port: Number(cli.getParamValue("port")), start, end });
|
||||
|
||||
const html = report.render({
|
||||
mode: isPid ? "pid" : "port",
|
||||
value: isPid ? Number(cli.getParamValue("pid")) : Number(cli.getParamValue("port")),
|
||||
rows,
|
||||
start,
|
||||
end
|
||||
});
|
||||
|
||||
fs.writeFileSync(reportFile, html, "utf8");
|
||||
$logger.info(`报告已生成: ${path.resolve(reportFile)}`);
|
||||
}
|
||||
|
||||
async function main(cli) {
|
||||
const pidText = cli.getParamValue("pid");
|
||||
const portText = cli.getParamValue("port");
|
||||
const reportRaw = cli.getParamValue("report");
|
||||
|
||||
const isPid = pidText != null && pidText !== "";
|
||||
const isPort = portText != null && portText !== "";
|
||||
const isReport = reportRaw !== undefined;
|
||||
|
||||
if (isPid && isPort) {
|
||||
$logger.error("--pid 和 --port 只能传递一个");
|
||||
process.exit(-1);
|
||||
}
|
||||
if (!isPid && !isPort) {
|
||||
$logger.error("--pid 和 --port 必须传递一个");
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
if (isPid) {
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
$logger.error(`pid 参数无效: ${pidText}`);
|
||||
process.exit(-1);
|
||||
}
|
||||
} else if (isPort) {
|
||||
const port = Number(portText);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
$logger.error(`port 参数无效: ${portText}`);
|
||||
process.exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
if (isReport) {
|
||||
const reportFile = reportRaw || "report.html";
|
||||
buildReport(cli, reportFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitor.isLinux()) {
|
||||
$logger.error("glance 仅支持 Linux 操作系统");
|
||||
process.exit(-1);
|
||||
}
|
||||
|
||||
await record(cli);
|
||||
}
|
||||
|
||||
module.exports = { main };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 进程监控指标采集(仅支持 Linux)
|
||||
* - CPU 占用率:两次采样 /proc/<pid>/stat 与 /proc/stat 差值计算,内部休眠约 1 秒
|
||||
* - 内存占用:/proc/<pid>/status 的 VmRSS,单位 MB
|
||||
* - 句柄数:/proc/<pid>/fd 目录条目数
|
||||
* - 端口 -> pid:解析 /proc/net/tcp 与 /proc/net/tcp6,结合 /proc/<pid>/fd 的 socket inode
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
function isLinux() {
|
||||
return os.type() === "Linux";
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readPidTicks(pid) {
|
||||
const txt = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
// 字段 14=utime 15=stime,comm 可能含空格,按最后一个 ) 截断
|
||||
const rest = txt.slice(txt.lastIndexOf(")") + 1).trim().split(/\s+/);
|
||||
return Number(rest[11]) + Number(rest[12]);
|
||||
}
|
||||
|
||||
function readCpuTotal() {
|
||||
const line = fs.readFileSync("/proc/stat", "utf8").split("\n")[0];
|
||||
// user nice system idle iowait irq softirq steal(guest 已计入 user)
|
||||
const parts = line.trim().split(/\s+/).slice(1, 9).map(Number);
|
||||
let total = 0;
|
||||
for (const v of parts) {
|
||||
total += Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function readMemMB(pid) {
|
||||
const status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
|
||||
const m = status.match(/^VmRSS:\s+(\d+)\s+kB/m);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(Number(m[1]) / 1024 * 100) / 100;
|
||||
}
|
||||
|
||||
function readHandleCount(pid) {
|
||||
return fs.readdirSync(`/proc/${pid}/fd`, { withFileTypes: true }).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集单个进程的监控数据
|
||||
* @param {number} pid
|
||||
* @returns {Promise<{pid:number,cpu:number,mem:number,handle:number}|null>} 进程不存在/不可读返回 null
|
||||
*/
|
||||
async function collectPid(pid) {
|
||||
try {
|
||||
const beforePid = readPidTicks(pid);
|
||||
const beforeTotal = readCpuTotal();
|
||||
await sleep(1000);
|
||||
const afterPid = readPidTicks(pid);
|
||||
const afterTotal = readCpuTotal();
|
||||
|
||||
const totalDelta = afterTotal - beforeTotal;
|
||||
const pidDelta = afterPid - beforePid;
|
||||
const cpu = totalDelta > 0 ? Math.round(pidDelta / totalDelta * 10000) / 100 : 0;
|
||||
|
||||
const mem = readMemMB(pid);
|
||||
const handle = readHandleCount(pid);
|
||||
return { pid, cpu, mem, handle };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据端口查找监听进程 pid
|
||||
* @param {number} port
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function findPortPid(port) {
|
||||
const targetInodes = new Set();
|
||||
|
||||
for (const file of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
const lines = text.split("\n").slice(1);
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 10) {
|
||||
continue;
|
||||
}
|
||||
// st == 0A 表示 LISTEN
|
||||
if (parts[3] !== "0A") {
|
||||
continue;
|
||||
}
|
||||
const local = parts[1];
|
||||
const idx = local.lastIndexOf(":");
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
const realPort = parseInt(local.slice(idx + 1), 16);
|
||||
if (Number.isFinite(realPort) && realPort === Number(port)) {
|
||||
targetInodes.add(parts[9]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetInodes.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync("/proc");
|
||||
for (const entry of entries) {
|
||||
if (!/^\d+$/.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
const fdDir = `/proc/${entry}/fd`;
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(fdDir);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
for (const fd of names) {
|
||||
try {
|
||||
const target = fs.readlinkSync(path.join(fdDir, fd));
|
||||
const m = target.match(/^socket:\[(\d+)\]$/);
|
||||
if (m && targetInodes.has(m[1])) {
|
||||
return Number(entry);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略无法读取的 fd
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLinux,
|
||||
collectPid,
|
||||
findPortPid
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* glance 报告生成:生成一个自包含 HTML 文件(内联 SVG 图表,无外部依赖)。
|
||||
* 三张图表:CPU 占用率、内存占用、句柄数。
|
||||
* - 纵轴/横轴根据数据量自动缩放
|
||||
* - 时间段内无数据或进程中停(时间缺口)时,图表会显现
|
||||
* - pid 变化(进程重启)以橙色虚线 + 标注标出
|
||||
*/
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function esc(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
const d = new Date(ts);
|
||||
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fmtFull(ts) {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function niceStep(raw) {
|
||||
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
||||
const norm = raw / mag;
|
||||
let step;
|
||||
if (norm <= 1) step = 1;
|
||||
else if (norm <= 2) step = 2;
|
||||
else if (norm <= 5) step = 5;
|
||||
else step = 10;
|
||||
return step * mag;
|
||||
}
|
||||
|
||||
function computeYAxis(values) {
|
||||
const vs = values.filter(v => Number.isFinite(v));
|
||||
if (!vs.length) {
|
||||
return { min: 0, max: 1, ticks: [0, 0.2, 0.4, 0.6, 0.8, 1] };
|
||||
}
|
||||
let min = Math.min(...vs);
|
||||
let max = Math.max(...vs);
|
||||
if (min === max) {
|
||||
min -= 1;
|
||||
max += 1;
|
||||
}
|
||||
const step = niceStep((max - min) / 5);
|
||||
const lo = Math.floor(min / step) * step;
|
||||
const hi = Math.ceil(max / step) * step;
|
||||
const ticks = [];
|
||||
for (let v = lo; v <= hi + step / 2; v += step) {
|
||||
ticks.push(Math.round(v * 1e6) / 1e6);
|
||||
}
|
||||
return { min: lo, max: hi, ticks };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成折线内容:按时间缺口切成多段 polyline,缺口处绘制淡色竖带
|
||||
*/
|
||||
function buildLine(entries, X, Y, gapThreshold, color) {
|
||||
let svg = "";
|
||||
let segment = [];
|
||||
const gaps = [];
|
||||
|
||||
function flush() {
|
||||
if (segment.length > 0) {
|
||||
const points = segment.map(p => `${X(p.ts).toFixed(2)},${Y(p.value).toFixed(2)}`).join(" ");
|
||||
svg += `\n <polyline fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" points="${points}"/>`;
|
||||
for (const p of segment) {
|
||||
svg += `\n <circle cx="${X(p.ts).toFixed(2)}" cy="${Y(p.value).toFixed(2)}" r="2.5" fill="${color}"/>`;
|
||||
}
|
||||
segment = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (i > 0 && gapThreshold > 0) {
|
||||
const dt = entries[i].ts - entries[i - 1].ts;
|
||||
if (dt > gapThreshold) {
|
||||
gaps.push({ x0: X(entries[i - 1].ts), x1: X(entries[i].ts) });
|
||||
flush();
|
||||
}
|
||||
}
|
||||
segment.push(entries[i]);
|
||||
}
|
||||
flush();
|
||||
return { svg, gaps };
|
||||
}
|
||||
|
||||
function buildChart(title, unit, color, rows, start, end, gapThreshold, pidChanges) {
|
||||
const W = 960, H = 300, mL = 80, mR = 40, mT = 36, mB = 52;
|
||||
const pw = W - mL - mR;
|
||||
const ph = H - mT - mB;
|
||||
const span = (end - start) || 1;
|
||||
|
||||
const entries = rows
|
||||
.filter(r => r.value != null && Number.isFinite(r.value))
|
||||
.map(r => ({ ts: r.ts, value: r.value, pid: r.pid }));
|
||||
|
||||
const X = ts => mL + Math.max(0, Math.min(1, (ts - start) / span)) * pw;
|
||||
const axis = computeYAxis(entries.map(e => e.value));
|
||||
const Y = v => mT + ph - ((v - axis.min) / ((axis.max - axis.min) || 1)) * ph;
|
||||
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="${H}" role="img">`;
|
||||
svg += `\n <text x="${mL}" y="22" font-size="13" font-weight="600" fill="#333">${esc(title)}${unit ? `(${esc(unit)})` : ""}</text>`;
|
||||
|
||||
// 网格与纵轴
|
||||
for (const t of axis.ticks) {
|
||||
const y = Y(t);
|
||||
svg += `\n <line x1="${mL}" y1="${y.toFixed(2)}" x2="${W - mR}" y2="${y.toFixed(2)}" stroke="#e5e7eb" stroke-width="1"/>`;
|
||||
svg += `\n <text x="${mL - 8}" y="${(y + 4).toFixed(2)}" font-size="11" fill="#6b7280" text-anchor="end">${t}</text>`;
|
||||
}
|
||||
|
||||
// 横轴(6 个刻度)
|
||||
for (let k = 0; k <= 5; k++) {
|
||||
const ts = start + (end - start) * k / 5;
|
||||
const x = X(ts);
|
||||
svg += `\n <line x1="${x.toFixed(2)}" y1="${mT + ph}" x2="${x.toFixed(2)}" y2="${mT + ph + 4}" stroke="#9ca3af" stroke-width="1"/>`;
|
||||
svg += `\n <text x="${x.toFixed(2)}" y="${mT + ph + 18}" font-size="11" fill="#6b7280" text-anchor="middle">${fmtTime(ts)}</text>`;
|
||||
}
|
||||
|
||||
// 坐标轴
|
||||
svg += `\n <line x1="${mL}" y1="${mT}" x2="${mL}" y2="${mT + ph}" stroke="#9ca3af" stroke-width="1"/>`;
|
||||
svg += `\n <line x1="${mL}" y1="${mT + ph}" x2="${W - mR}" y2="${mT + ph}" stroke="#9ca3af" stroke-width="1"/>`;
|
||||
|
||||
if (entries.length === 0) {
|
||||
svg += `\n <text x="${mL + pw / 2}" y="${mT + ph / 2}" font-size="14" fill="#9ca3af" text-anchor="middle">该时间范围无数据</text>`;
|
||||
} else {
|
||||
const { svg: lineSvg, gaps } = buildLine(entries, X, Y, gapThreshold, color);
|
||||
svg += lineSvg;
|
||||
|
||||
// 时间缺口高亮(一段时间没数据)
|
||||
for (const g of gaps) {
|
||||
const x = Math.min(g.x0, g.x1);
|
||||
const w = Math.max(1, Math.abs(g.x1 - g.x0));
|
||||
svg += `\n <rect x="${x.toFixed(2)}" y="${(mT + 6).toFixed(2)}" width="${w.toFixed(2)}" height="${(ph - 12).toFixed(2)}" fill="#f3f4f6" stroke="#d1d5db" stroke-width="0.5"/>`;
|
||||
}
|
||||
|
||||
// pid 变化(进程重启)竖线
|
||||
if (pidChanges && pidChanges.length) {
|
||||
for (const ch of pidChanges) {
|
||||
const x = X(ch.ts);
|
||||
svg += `\n <line x1="${x.toFixed(2)}" y1="${mT}" x2="${x.toFixed(2)}" y2="${mT + ph}" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>`;
|
||||
const label = `pid ${ch.from}→${ch.to}`;
|
||||
const lx = Math.min(x + 5, W - mR - 110);
|
||||
const ly = mT + 14;
|
||||
svg += `\n <text x="${lx.toFixed(2)}" y="${ly}" font-size="11" fill="#b45309" stroke="#ffffff" stroke-width="3" paint-order="stroke">↻ ${esc(label)}</text>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg += "\n</svg>";
|
||||
return svg;
|
||||
}
|
||||
|
||||
function computeGapThreshold(rows) {
|
||||
const tsList = rows
|
||||
.map(r => r.ts)
|
||||
.filter(ts => Number.isFinite(ts))
|
||||
.sort((a, b) => a - b);
|
||||
if (tsList.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
const deltas = [];
|
||||
for (let i = 1; i < tsList.length; i++) {
|
||||
const d = tsList[i] - tsList[i - 1];
|
||||
if (d > 0) {
|
||||
deltas.push(d);
|
||||
}
|
||||
}
|
||||
if (!deltas.length) {
|
||||
return 0;
|
||||
}
|
||||
deltas.sort((a, b) => a - b);
|
||||
const typical = deltas[Math.floor(deltas.length / 2)];
|
||||
return Math.max(typical * 3, 30 * 1000);
|
||||
}
|
||||
|
||||
function collectPidChanges(rows) {
|
||||
const changes = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const prev = rows[i - 1].pid;
|
||||
const cur = rows[i].pid;
|
||||
if (prev != null && cur != null && prev !== cur) {
|
||||
changes.push({ ts: rows[i].ts, from: prev, to: cur });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function summary(rows, key) {
|
||||
const vs = rows.map(r => r[key]).filter(v => v != null && Number.isFinite(v));
|
||||
if (!vs.length) {
|
||||
return null;
|
||||
}
|
||||
const min = Math.min(...vs);
|
||||
const max = Math.max(...vs);
|
||||
const avg = vs.reduce((a, b) => a + b, 0) / vs.length;
|
||||
return { min, max, avg };
|
||||
}
|
||||
|
||||
function fmtNum(v, digits) {
|
||||
if (v == null || !Number.isFinite(v)) {
|
||||
return "-";
|
||||
}
|
||||
return Number(v.toFixed(digits));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成报告 HTML
|
||||
* @param opts {mode:'pid'|'port', value, rows, start, end}
|
||||
*/
|
||||
function render(opts) {
|
||||
const { mode, value, rows, start, end } = opts;
|
||||
const spanHours = Math.round((end - start) / HOUR * 10) / 10;
|
||||
const targetText = mode === "pid" ? `--pid ${value}` : `--port ${value}`;
|
||||
const gapThreshold = computeGapThreshold(rows);
|
||||
const pidChanges = collectPidChanges(rows);
|
||||
const restarts = pidChanges.length;
|
||||
const generatedAt = fmtFull(Date.now());
|
||||
|
||||
const series = [
|
||||
{ key: "cpu", title: "CPU 占用率", unit: "%", color: "#2563eb" },
|
||||
{ key: "mem", title: "内存占用", unit: "MB", color: "#16a34a" },
|
||||
{ key: "handle", title: "句柄数", unit: "", color: "#dc2626" }
|
||||
];
|
||||
|
||||
const summaryRows = series.map(s => {
|
||||
const st = summary(rows, s.key);
|
||||
return {
|
||||
title: s.title,
|
||||
unit: s.unit,
|
||||
min: st ? fmtNum(st.min, 2) : "-",
|
||||
max: st ? fmtNum(st.max, 2) : "-",
|
||||
avg: st ? fmtNum(st.avg, 2) : "-"
|
||||
};
|
||||
});
|
||||
|
||||
let chartsHtml = "";
|
||||
for (const s of series) {
|
||||
chartsHtml += `
|
||||
<section class="chart">
|
||||
<h2>${esc(s.title)}</h2>
|
||||
${buildChart(s.title, s.unit, s.color, rows.map(r => ({ ts: r.ts, value: r[s.key], pid: r.pid })), start, end, gapThreshold, pidChanges)}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const summaryHtml = summaryRows.map(r => `
|
||||
<tr>
|
||||
<td>${esc(r.title)}${r.unit ? `(${esc(r.unit)})` : ""}</td>
|
||||
<td>${r.min}</td>
|
||||
<td>${r.max}</td>
|
||||
<td>${r.avg}</td>
|
||||
</tr>`).join("");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>进程监控报告 ${esc(targetText)}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f9fafb; margin: 0; padding: 24px; color: #1f2937; }
|
||||
.report { max-width: 1040px; margin: 0 auto; }
|
||||
header { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px 24px; margin-bottom: 16px; }
|
||||
header h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 8px 32px; font-size: 13px; color: #4b5563; }
|
||||
.meta b { color: #111827; }
|
||||
.charts { display: flex; flex-direction: column; gap: 16px; }
|
||||
.chart { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px 8px; }
|
||||
.chart h2 { margin: 0 0 4px; font-size: 15px; color: #374151; }
|
||||
.legend { color: #6b7280; font-size: 12px; margin-top: 4px; }
|
||||
table { border-collapse: collapse; margin-top: 12px; font-size: 13px; width: 100%; }
|
||||
th, td { text-align: right; padding: 6px 12px; border-bottom: 1px solid #e5e7eb; }
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
th { color: #6b7280; font-weight: 500; }
|
||||
footer { margin-top: 16px; font-size: 12px; color: #9ca3af; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report">
|
||||
<header>
|
||||
<h1>进程监控报告</h1>
|
||||
<div class="meta">
|
||||
<span>监控目标:<b>${esc(targetText)}</b></span>
|
||||
<span>时间范围:<b>${fmtFull(start)} ~ ${fmtFull(end)}</b>(约 ${spanHours} 小时)</span>
|
||||
<span>样本数:<b>${rows.length}</b></span>
|
||||
<span>重启次数:<b>${restarts}</b></span>
|
||||
<span>生成时间:<b>${generatedAt}</b></span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>指标</th><th>最小值</th><th>最大值</th><th>平均值</th></tr></thead>
|
||||
<tbody>${summaryHtml}</tbody>
|
||||
</table>
|
||||
<div class="legend">说明:时间轴覆盖请求范围;灰色竖带表示其间无采样数据(进程中断/采集缺失);橙色虚线表示 pid 变化(进程重启)。</div>
|
||||
</header>
|
||||
<div class="charts">
|
||||
${chartsHtml}
|
||||
</div>
|
||||
<footer>generated by kit glance</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = { render };
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* glance 数据存储:默认 sqlite 位于 ~/.kit/glance.db(不存在则创建)
|
||||
* 依赖 bun:sqlite,仅在调用时加载,避免影响 node 环境下的其他命令。
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
function defaultDbPath() {
|
||||
return path.join(os.homedir(), ".kit", "glance.db");
|
||||
}
|
||||
|
||||
function open(dbPath) {
|
||||
dbPath = dbPath || defaultDbPath();
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
|
||||
const { Database } = require("bun:sqlite");
|
||||
const db = new Database(dbPath);
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS glance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
pid INTEGER,
|
||||
port INTEGER,
|
||||
cpu REAL,
|
||||
mem REAL,
|
||||
handle INTEGER
|
||||
)`);
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_glance_lookup ON glance(pid, port, ts)");
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入一条监控记录
|
||||
* @param db database
|
||||
* @param record {ts, target, pid, port, cpu, mem, handle}
|
||||
*/
|
||||
function insert(db, record) {
|
||||
db.query("INSERT INTO glance (ts, target, pid, port, cpu, mem, handle) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(record.ts, record.target, record.pid ?? null, record.port ?? null, record.cpu ?? null, record.mem ?? null, record.handle ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按时间范围查询监控数据(忽略 target,pid/port 二者取其一)
|
||||
* @param db database
|
||||
* @param opts {pid?, port?, start, end}
|
||||
* @returns rows
|
||||
*/
|
||||
function queryRange(db, opts) {
|
||||
let where;
|
||||
const params = [];
|
||||
if (opts.pid != null) {
|
||||
where = "pid = ?";
|
||||
params.push(opts.pid);
|
||||
} else {
|
||||
where = "port = ?";
|
||||
params.push(opts.port);
|
||||
}
|
||||
params.push(opts.start, opts.end);
|
||||
return db.query(`SELECT ts, target, pid, port, cpu, mem, handle FROM glance WHERE ${where} AND ts >= ? AND ts < ? ORDER BY ts ASC`).all(...params);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
defaultDbPath,
|
||||
open,
|
||||
insert,
|
||||
queryRange
|
||||
};
|
||||
@@ -15,6 +15,7 @@ const zlib = require("zlib");
|
||||
const mem = require("./mem")
|
||||
const { getAllDeviceInfo } = require("./platform")
|
||||
const kitcommand = require("./kitcommand")
|
||||
const glance = require("./glance")
|
||||
kitcommand.init()
|
||||
kitcommand.regTo( parser )
|
||||
|
||||
@@ -188,6 +189,10 @@ parser.addCmdLine("killport <port>", "释放端口", function (cli) {
|
||||
net.killport( parseInt(port, 10) );
|
||||
});
|
||||
|
||||
parser.addCmdLine("glance [--pid] [--port] [--report] [--startTime] [--endTime]", "进程监控:--pid/--port 采集监控数据(仅 Linux),--report 生成报告并输出 HTML;", async function (cli) {
|
||||
await glance.main(cli);
|
||||
});
|
||||
|
||||
|
||||
parser.addCmdLine("getport [--port]", "获取一个可用端口;或查询指定端口状态;", async function (cli) {
|
||||
const getPort = await import('get-port').then(psl => psl.default);
|
||||
|
||||
Reference in New Issue
Block a user