refactor: Modularize server stats widget collectors
This commit is contained in:
@@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2";
|
|||||||
import { getDb } from "../database/db/index.js";
|
import { getDb } from "../database/db/index.js";
|
||||||
import { sshData, sshCredentials } from "../database/db/schema.js";
|
import { sshData, sshCredentials } from "../database/db/schema.js";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { statsLogger } from "../utils/logger.js";
|
import { statsLogger, sshLogger } from "../utils/logger.js";
|
||||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||||
import { AuthManager } from "../utils/auth-manager.js";
|
import { AuthManager } from "../utils/auth-manager.js";
|
||||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||||
|
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||||
|
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||||
|
import { collectDiskMetrics } from "./widgets/disk-collector.js";
|
||||||
|
import { collectNetworkMetrics } from "./widgets/network-collector.js";
|
||||||
|
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
||||||
|
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||||
|
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||||
|
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||||
|
|
||||||
interface PooledConnection {
|
interface PooledConnection {
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -924,59 +932,6 @@ async function withSshConnection<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function execCommand(
|
|
||||||
client: Client,
|
|
||||||
command: string,
|
|
||||||
): Promise<{
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
code: number | null;
|
|
||||||
}> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
client.exec(command, { pty: false }, (err, stream) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
let exitCode: number | null = null;
|
|
||||||
stream
|
|
||||||
.on("close", (code: number | undefined) => {
|
|
||||||
exitCode = typeof code === "number" ? code : null;
|
|
||||||
resolve({ stdout, stderr, code: exitCode });
|
|
||||||
})
|
|
||||||
.on("data", (data: Buffer) => {
|
|
||||||
stdout += data.toString("utf8");
|
|
||||||
})
|
|
||||||
.stderr.on("data", (data: Buffer) => {
|
|
||||||
stderr += data.toString("utf8");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCpuLine(
|
|
||||||
cpuLine: string,
|
|
||||||
): { total: number; idle: number } | undefined {
|
|
||||||
const parts = cpuLine.trim().split(/\s+/);
|
|
||||||
if (parts[0] !== "cpu") return undefined;
|
|
||||||
const nums = parts
|
|
||||||
.slice(1)
|
|
||||||
.map((n) => Number(n))
|
|
||||||
.filter((n) => Number.isFinite(n));
|
|
||||||
if (nums.length < 4) return undefined;
|
|
||||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
|
||||||
const total = nums.reduce((a, b) => a + b, 0);
|
|
||||||
return { total, idle };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
|
||||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
|
||||||
return Number(n.toFixed(digits));
|
|
||||||
}
|
|
||||||
|
|
||||||
function kibToGiB(kib: number): number {
|
|
||||||
return kib / (1024 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||||
cpu: {
|
cpu: {
|
||||||
percent: number | null;
|
percent: number | null;
|
||||||
@@ -1039,318 +994,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
return requestQueue.queueRequest(host.id, async () => {
|
return requestQueue.queueRequest(host.id, async () => {
|
||||||
try {
|
try {
|
||||||
return await withSshConnection(host, async (client) => {
|
return await withSshConnection(host, async (client) => {
|
||||||
let cpuPercent: number | null = null;
|
const cpu = await collectCpuMetrics(client);
|
||||||
let cores: number | null = null;
|
const memory = await collectMemoryMetrics(client);
|
||||||
let loadTriplet: [number, number, number] | null = null;
|
const disk = await collectDiskMetrics(client);
|
||||||
|
const network = await collectNetworkMetrics(client);
|
||||||
|
const uptime = await collectUptimeMetrics(client);
|
||||||
|
const processes = await collectProcessesMetrics(client);
|
||||||
|
const system = await collectSystemMetrics(client);
|
||||||
|
|
||||||
|
let login_stats = {
|
||||||
|
recentLogins: [],
|
||||||
|
failedLogins: [],
|
||||||
|
totalLogins: 0,
|
||||||
|
uniqueIPs: 0,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
login_stats = await collectLoginStats(client);
|
||||||
execCommand(client, "cat /proc/stat"),
|
|
||||||
execCommand(client, "cat /proc/loadavg"),
|
|
||||||
execCommand(
|
|
||||||
client,
|
|
||||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
const stat2 = await execCommand(client, "cat /proc/stat");
|
|
||||||
|
|
||||||
const cpuLine1 = (
|
|
||||||
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
|
||||||
).trim();
|
|
||||||
const cpuLine2 = (
|
|
||||||
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
|
||||||
).trim();
|
|
||||||
const a = parseCpuLine(cpuLine1);
|
|
||||||
const b = parseCpuLine(cpuLine2);
|
|
||||||
if (a && b) {
|
|
||||||
const totalDiff = b.total - a.total;
|
|
||||||
const idleDiff = b.idle - a.idle;
|
|
||||||
const used = totalDiff - idleDiff;
|
|
||||||
if (totalDiff > 0)
|
|
||||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
|
||||||
if (laParts.length >= 3) {
|
|
||||||
loadTriplet = [
|
|
||||||
Number(laParts[0]),
|
|
||||||
Number(laParts[1]),
|
|
||||||
Number(laParts[2]),
|
|
||||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
number,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const coresNum = Number((coresOut.stdout || "").trim());
|
|
||||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
cpuPercent = null;
|
statsLogger.debug("Failed to collect login stats", {
|
||||||
cores = null;
|
operation: "login_stats_failed",
|
||||||
loadTriplet = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let memPercent: number | null = null;
|
|
||||||
let usedGiB: number | null = null;
|
|
||||||
let totalGiB: number | null = null;
|
|
||||||
try {
|
|
||||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
|
||||||
const lines = memInfo.stdout.split("\n");
|
|
||||||
const getVal = (key: string) => {
|
|
||||||
const line = lines.find((l) => l.startsWith(key));
|
|
||||||
if (!line) return null;
|
|
||||||
const m = line.match(/\d+/);
|
|
||||||
return m ? Number(m[0]) : null;
|
|
||||||
};
|
|
||||||
const totalKb = getVal("MemTotal:");
|
|
||||||
const availKb = getVal("MemAvailable:");
|
|
||||||
if (totalKb && availKb && totalKb > 0) {
|
|
||||||
const usedKb = totalKb - availKb;
|
|
||||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
|
||||||
usedGiB = kibToGiB(usedKb);
|
|
||||||
totalGiB = kibToGiB(totalKb);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
memPercent = null;
|
|
||||||
usedGiB = null;
|
|
||||||
totalGiB = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let diskPercent: number | null = null;
|
|
||||||
let usedHuman: string | null = null;
|
|
||||||
let totalHuman: string | null = null;
|
|
||||||
let availableHuman: string | null = null;
|
|
||||||
try {
|
|
||||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
|
||||||
execCommand(client, "df -h -P / | tail -n +2"),
|
|
||||||
execCommand(client, "df -B1 -P / | tail -n +2"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const humanLine =
|
|
||||||
diskOutHuman.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)[0] || "";
|
|
||||||
const bytesLine =
|
|
||||||
diskOutBytes.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean)[0] || "";
|
|
||||||
|
|
||||||
const humanParts = humanLine.split(/\s+/);
|
|
||||||
const bytesParts = bytesLine.split(/\s+/);
|
|
||||||
|
|
||||||
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
|
||||||
totalHuman = humanParts[1] || null;
|
|
||||||
usedHuman = humanParts[2] || null;
|
|
||||||
availableHuman = humanParts[3] || null;
|
|
||||||
|
|
||||||
const totalBytes = Number(bytesParts[1]);
|
|
||||||
const usedBytes = Number(bytesParts[2]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
Number.isFinite(totalBytes) &&
|
|
||||||
Number.isFinite(usedBytes) &&
|
|
||||||
totalBytes > 0
|
|
||||||
) {
|
|
||||||
diskPercent = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, (usedBytes / totalBytes) * 100),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
diskPercent = null;
|
|
||||||
usedHuman = null;
|
|
||||||
totalHuman = null;
|
|
||||||
availableHuman = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interfaces: Array<{
|
|
||||||
name: string;
|
|
||||||
ip: string;
|
|
||||||
state: string;
|
|
||||||
rxBytes: string | null;
|
|
||||||
txBytes: string | null;
|
|
||||||
}> = [];
|
|
||||||
try {
|
|
||||||
const ifconfigOut = await execCommand(
|
|
||||||
client,
|
|
||||||
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
|
|
||||||
);
|
|
||||||
const netStatOut = await execCommand(
|
|
||||||
client,
|
|
||||||
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
|
||||||
);
|
|
||||||
|
|
||||||
const addrs = ifconfigOut.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const states = netStatOut.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const ifMap = new Map<string, { ip: string; state: string }>();
|
|
||||||
for (const line of addrs) {
|
|
||||||
const parts = line.split(/\s+/);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const name = parts[0];
|
|
||||||
const ip = parts[1].split("/")[0];
|
|
||||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const line of states) {
|
|
||||||
const parts = line.split(/\s+/);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
const name = parts[0];
|
|
||||||
const state = parts[1];
|
|
||||||
const existing = ifMap.get(name);
|
|
||||||
if (existing) {
|
|
||||||
existing.state = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [name, data] of ifMap.entries()) {
|
|
||||||
interfaces.push({
|
|
||||||
name,
|
|
||||||
ip: data.ip,
|
|
||||||
state: data.state,
|
|
||||||
rxBytes: null,
|
|
||||||
txBytes: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
statsLogger.debug("Failed to collect network interface stats", {
|
|
||||||
operation: "network_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let uptimeSeconds: number | null = null;
|
|
||||||
let uptimeFormatted: string | null = null;
|
|
||||||
try {
|
|
||||||
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
|
||||||
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
|
||||||
if (uptimeParts.length >= 1) {
|
|
||||||
uptimeSeconds = Number(uptimeParts[0]);
|
|
||||||
if (Number.isFinite(uptimeSeconds)) {
|
|
||||||
const days = Math.floor(uptimeSeconds / 86400);
|
|
||||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
|
||||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
|
||||||
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
statsLogger.debug("Failed to collect uptime", {
|
|
||||||
operation: "uptime_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalProcesses: number | null = null;
|
|
||||||
let runningProcesses: number | null = null;
|
|
||||||
const topProcesses: Array<{
|
|
||||||
pid: string;
|
|
||||||
user: string;
|
|
||||||
cpu: string;
|
|
||||||
mem: string;
|
|
||||||
command: string;
|
|
||||||
}> = [];
|
|
||||||
try {
|
|
||||||
const psOut = await execCommand(
|
|
||||||
client,
|
|
||||||
"ps aux --sort=-%cpu | head -n 11",
|
|
||||||
);
|
|
||||||
const psLines = psOut.stdout
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (psLines.length > 1) {
|
|
||||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
|
||||||
const parts = psLines[i].split(/\s+/);
|
|
||||||
if (parts.length >= 11) {
|
|
||||||
topProcesses.push({
|
|
||||||
pid: parts[1],
|
|
||||||
user: parts[0],
|
|
||||||
cpu: parts[2],
|
|
||||||
mem: parts[3],
|
|
||||||
command: parts.slice(10).join(" ").substring(0, 50),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
|
||||||
const runningCount = await execCommand(
|
|
||||||
client,
|
|
||||||
"ps aux | grep -c ' R '",
|
|
||||||
);
|
|
||||||
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
|
||||||
runningProcesses = Number(runningCount.stdout.trim());
|
|
||||||
} catch (e) {
|
|
||||||
statsLogger.debug("Failed to collect process stats", {
|
|
||||||
operation: "process_stats_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostname: string | null = null;
|
|
||||||
let kernel: string | null = null;
|
|
||||||
let os: string | null = null;
|
|
||||||
try {
|
|
||||||
const hostnameOut = await execCommand(client, "hostname");
|
|
||||||
const kernelOut = await execCommand(client, "uname -r");
|
|
||||||
const osOut = await execCommand(
|
|
||||||
client,
|
|
||||||
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
|
|
||||||
);
|
|
||||||
|
|
||||||
hostname = hostnameOut.stdout.trim() || null;
|
|
||||||
kernel = kernelOut.stdout.trim() || null;
|
|
||||||
os = osOut.stdout.trim() || null;
|
|
||||||
} catch (e) {
|
|
||||||
statsLogger.debug("Failed to collect system info", {
|
|
||||||
operation: "system_info_failed",
|
|
||||||
error: e instanceof Error ? e.message : String(e),
|
error: e instanceof Error ? e.message : String(e),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
|
cpu,
|
||||||
memory: {
|
memory,
|
||||||
percent: toFixedNum(memPercent, 0),
|
disk,
|
||||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
network,
|
||||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
uptime,
|
||||||
},
|
processes,
|
||||||
disk: {
|
system,
|
||||||
percent: toFixedNum(diskPercent, 0),
|
login_stats,
|
||||||
usedHuman,
|
|
||||||
totalHuman,
|
|
||||||
availableHuman,
|
|
||||||
},
|
|
||||||
network: {
|
|
||||||
interfaces,
|
|
||||||
},
|
|
||||||
uptime: {
|
|
||||||
seconds: uptimeSeconds,
|
|
||||||
formatted: uptimeFormatted,
|
|
||||||
},
|
|
||||||
processes: {
|
|
||||||
total: totalProcesses,
|
|
||||||
running: runningProcesses,
|
|
||||||
top: topProcesses,
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
hostname,
|
|
||||||
kernel,
|
|
||||||
os,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
metricsCache.set(host.id, result);
|
metricsCache.set(host.id, result);
|
||||||
|
|||||||
39
src/backend/ssh/widgets/common-utils.ts
Normal file
39
src/backend/ssh/widgets/common-utils.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
|
||||||
|
export function execCommand(
|
||||||
|
client: Client,
|
||||||
|
command: string,
|
||||||
|
): Promise<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
code: number | null;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
client.exec(command, { pty: false }, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let exitCode: number | null = null;
|
||||||
|
stream
|
||||||
|
.on("close", (code: number | undefined) => {
|
||||||
|
exitCode = typeof code === "number" ? code : null;
|
||||||
|
resolve({ stdout, stderr, code: exitCode });
|
||||||
|
})
|
||||||
|
.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString("utf8");
|
||||||
|
})
|
||||||
|
.stderr.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString("utf8");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
||||||
|
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||||
|
return Number(n.toFixed(digits));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kibToGiB(kib: number): number {
|
||||||
|
return kib / (1024 * 1024);
|
||||||
|
}
|
||||||
83
src/backend/ssh/widgets/cpu-collector.ts
Normal file
83
src/backend/ssh/widgets/cpu-collector.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||||
|
|
||||||
|
function parseCpuLine(
|
||||||
|
cpuLine: string,
|
||||||
|
): { total: number; idle: number } | undefined {
|
||||||
|
const parts = cpuLine.trim().split(/\s+/);
|
||||||
|
if (parts[0] !== "cpu") return undefined;
|
||||||
|
const nums = parts
|
||||||
|
.slice(1)
|
||||||
|
.map((n) => Number(n))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
if (nums.length < 4) return undefined;
|
||||||
|
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||||
|
const total = nums.reduce((a, b) => a + b, 0);
|
||||||
|
return { total, idle };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectCpuMetrics(client: Client): Promise<{
|
||||||
|
percent: number | null;
|
||||||
|
cores: number | null;
|
||||||
|
load: [number, number, number] | null;
|
||||||
|
}> {
|
||||||
|
let cpuPercent: number | null = null;
|
||||||
|
let cores: number | null = null;
|
||||||
|
let loadTriplet: [number, number, number] | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stat1, loadAvgOut, coresOut] = await Promise.all([
|
||||||
|
execCommand(client, "cat /proc/stat"),
|
||||||
|
execCommand(client, "cat /proc/loadavg"),
|
||||||
|
execCommand(
|
||||||
|
client,
|
||||||
|
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
const stat2 = await execCommand(client, "cat /proc/stat");
|
||||||
|
|
||||||
|
const cpuLine1 = (
|
||||||
|
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||||
|
).trim();
|
||||||
|
const cpuLine2 = (
|
||||||
|
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||||
|
).trim();
|
||||||
|
const a = parseCpuLine(cpuLine1);
|
||||||
|
const b = parseCpuLine(cpuLine2);
|
||||||
|
if (a && b) {
|
||||||
|
const totalDiff = b.total - a.total;
|
||||||
|
const idleDiff = b.idle - a.idle;
|
||||||
|
const used = totalDiff - idleDiff;
|
||||||
|
if (totalDiff > 0)
|
||||||
|
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||||
|
if (laParts.length >= 3) {
|
||||||
|
loadTriplet = [
|
||||||
|
Number(laParts[0]),
|
||||||
|
Number(laParts[1]),
|
||||||
|
Number(laParts[2]),
|
||||||
|
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const coresNum = Number((coresOut.stdout || "").trim());
|
||||||
|
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||||
|
} catch (e) {
|
||||||
|
cpuPercent = null;
|
||||||
|
cores = null;
|
||||||
|
loadTriplet = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent: toFixedNum(cpuPercent, 0),
|
||||||
|
cores,
|
||||||
|
load: loadTriplet,
|
||||||
|
};
|
||||||
|
}
|
||||||
67
src/backend/ssh/widgets/disk-collector.ts
Normal file
67
src/backend/ssh/widgets/disk-collector.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||||
|
|
||||||
|
export async function collectDiskMetrics(client: Client): Promise<{
|
||||||
|
percent: number | null;
|
||||||
|
usedHuman: string | null;
|
||||||
|
totalHuman: string | null;
|
||||||
|
availableHuman: string | null;
|
||||||
|
}> {
|
||||||
|
let diskPercent: number | null = null;
|
||||||
|
let usedHuman: string | null = null;
|
||||||
|
let totalHuman: string | null = null;
|
||||||
|
let availableHuman: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||||
|
execCommand(client, "df -h -P / | tail -n +2"),
|
||||||
|
execCommand(client, "df -B1 -P / | tail -n +2"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const humanLine =
|
||||||
|
diskOutHuman.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)[0] || "";
|
||||||
|
const bytesLine =
|
||||||
|
diskOutBytes.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)[0] || "";
|
||||||
|
|
||||||
|
const humanParts = humanLine.split(/\s+/);
|
||||||
|
const bytesParts = bytesLine.split(/\s+/);
|
||||||
|
|
||||||
|
if (humanParts.length >= 6 && bytesParts.length >= 6) {
|
||||||
|
totalHuman = humanParts[1] || null;
|
||||||
|
usedHuman = humanParts[2] || null;
|
||||||
|
availableHuman = humanParts[3] || null;
|
||||||
|
|
||||||
|
const totalBytes = Number(bytesParts[1]);
|
||||||
|
const usedBytes = Number(bytesParts[2]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isFinite(totalBytes) &&
|
||||||
|
Number.isFinite(usedBytes) &&
|
||||||
|
totalBytes > 0
|
||||||
|
) {
|
||||||
|
diskPercent = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, (usedBytes / totalBytes) * 100),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
diskPercent = null;
|
||||||
|
usedHuman = null;
|
||||||
|
totalHuman = null;
|
||||||
|
availableHuman = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent: toFixedNum(diskPercent, 0),
|
||||||
|
usedHuman,
|
||||||
|
totalHuman,
|
||||||
|
availableHuman,
|
||||||
|
};
|
||||||
|
}
|
||||||
117
src/backend/ssh/widgets/login-stats-collector.ts
Normal file
117
src/backend/ssh/widgets/login-stats-collector.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
|
||||||
|
export interface LoginRecord {
|
||||||
|
user: string;
|
||||||
|
ip: string;
|
||||||
|
time: string;
|
||||||
|
status: "success" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginStats {
|
||||||
|
recentLogins: LoginRecord[];
|
||||||
|
failedLogins: LoginRecord[];
|
||||||
|
totalLogins: number;
|
||||||
|
uniqueIPs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||||
|
const recentLogins: LoginRecord[] = [];
|
||||||
|
const failedLogins: LoginRecord[] = [];
|
||||||
|
const ipSet = new Set<string>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lastOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastLines = lastOut.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of lastLines) {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 10) {
|
||||||
|
const user = parts[0];
|
||||||
|
const tty = parts[1];
|
||||||
|
const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||||
|
|
||||||
|
const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "");
|
||||||
|
if (timeStart > 0 && parts.length > timeStart + 4) {
|
||||||
|
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||||
|
|
||||||
|
if (user && user !== "wtmp" && tty !== "system") {
|
||||||
|
recentLogins.push({
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
time: new Date(timeStr).toISOString(),
|
||||||
|
status: "success",
|
||||||
|
});
|
||||||
|
if (ip !== "local") {
|
||||||
|
ipSet.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const failedOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedLines = failedOut.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
for (const line of failedLines) {
|
||||||
|
let user = "unknown";
|
||||||
|
let ip = "unknown";
|
||||||
|
let timeStr = "";
|
||||||
|
|
||||||
|
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
|
||||||
|
if (userMatch) {
|
||||||
|
user = userMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
if (ipMatch) {
|
||||||
|
ip = ipMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
timeStr = `${currentYear} ${dateMatch[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && ip) {
|
||||||
|
failedLogins.push({
|
||||||
|
user,
|
||||||
|
ip,
|
||||||
|
time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(),
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
if (ip !== "unknown") {
|
||||||
|
ipSet.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentLogins: recentLogins.slice(0, 10),
|
||||||
|
failedLogins: failedLogins.slice(0, 10),
|
||||||
|
totalLogins: recentLogins.length,
|
||||||
|
uniqueIPs: ipSet.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
41
src/backend/ssh/widgets/memory-collector.ts
Normal file
41
src/backend/ssh/widgets/memory-collector.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js";
|
||||||
|
|
||||||
|
export async function collectMemoryMetrics(client: Client): Promise<{
|
||||||
|
percent: number | null;
|
||||||
|
usedGiB: number | null;
|
||||||
|
totalGiB: number | null;
|
||||||
|
}> {
|
||||||
|
let memPercent: number | null = null;
|
||||||
|
let usedGiB: number | null = null;
|
||||||
|
let totalGiB: number | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||||
|
const lines = memInfo.stdout.split("\n");
|
||||||
|
const getVal = (key: string) => {
|
||||||
|
const line = lines.find((l) => l.startsWith(key));
|
||||||
|
if (!line) return null;
|
||||||
|
const m = line.match(/\d+/);
|
||||||
|
return m ? Number(m[0]) : null;
|
||||||
|
};
|
||||||
|
const totalKb = getVal("MemTotal:");
|
||||||
|
const availKb = getVal("MemAvailable:");
|
||||||
|
if (totalKb && availKb && totalKb > 0) {
|
||||||
|
const usedKb = totalKb - availKb;
|
||||||
|
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||||
|
usedGiB = kibToGiB(usedKb);
|
||||||
|
totalGiB = kibToGiB(totalKb);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
memPercent = null;
|
||||||
|
usedGiB = null;
|
||||||
|
totalGiB = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
percent: toFixedNum(memPercent, 0),
|
||||||
|
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||||
|
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
src/backend/ssh/widgets/network-collector.ts
Normal file
79
src/backend/ssh/widgets/network-collector.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import { statsLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
export async function collectNetworkMetrics(client: Client): Promise<{
|
||||||
|
interfaces: Array<{
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
state: string;
|
||||||
|
rxBytes: string | null;
|
||||||
|
txBytes: string | null;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const interfaces: Array<{
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
state: string;
|
||||||
|
rxBytes: string | null;
|
||||||
|
txBytes: string | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ifconfigOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
|
||||||
|
);
|
||||||
|
const netStatOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
||||||
|
);
|
||||||
|
|
||||||
|
const addrs = ifconfigOut.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const states = netStatOut.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const ifMap = new Map<string, { ip: string; state: string }>();
|
||||||
|
for (const line of addrs) {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const name = parts[0];
|
||||||
|
const ip = parts[1].split("/")[0];
|
||||||
|
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const line of states) {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const name = parts[0];
|
||||||
|
const state = parts[1];
|
||||||
|
const existing = ifMap.get(name);
|
||||||
|
if (existing) {
|
||||||
|
existing.state = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, data] of ifMap.entries()) {
|
||||||
|
interfaces.push({
|
||||||
|
name,
|
||||||
|
ip: data.ip,
|
||||||
|
state: data.state,
|
||||||
|
rxBytes: null,
|
||||||
|
txBytes: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect network interface stats", {
|
||||||
|
operation: "network_stats_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { interfaces };
|
||||||
|
}
|
||||||
69
src/backend/ssh/widgets/processes-collector.ts
Normal file
69
src/backend/ssh/widgets/processes-collector.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import { statsLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
export async function collectProcessesMetrics(client: Client): Promise<{
|
||||||
|
total: number | null;
|
||||||
|
running: number | null;
|
||||||
|
top: Array<{
|
||||||
|
pid: string;
|
||||||
|
user: string;
|
||||||
|
cpu: string;
|
||||||
|
mem: string;
|
||||||
|
command: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
let totalProcesses: number | null = null;
|
||||||
|
let runningProcesses: number | null = null;
|
||||||
|
const topProcesses: Array<{
|
||||||
|
pid: string;
|
||||||
|
user: string;
|
||||||
|
cpu: string;
|
||||||
|
mem: string;
|
||||||
|
command: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const psOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"ps aux --sort=-%cpu | head -n 11",
|
||||||
|
);
|
||||||
|
const psLines = psOut.stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (psLines.length > 1) {
|
||||||
|
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||||
|
const parts = psLines[i].split(/\s+/);
|
||||||
|
if (parts.length >= 11) {
|
||||||
|
topProcesses.push({
|
||||||
|
pid: parts[1],
|
||||||
|
user: parts[0],
|
||||||
|
cpu: parts[2],
|
||||||
|
mem: parts[3],
|
||||||
|
command: parts.slice(10).join(" ").substring(0, 50),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||||
|
const runningCount = await execCommand(
|
||||||
|
client,
|
||||||
|
"ps aux | grep -c ' R '",
|
||||||
|
);
|
||||||
|
totalProcesses = Number(procCount.stdout.trim()) - 1;
|
||||||
|
runningProcesses = Number(runningCount.stdout.trim());
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect process stats", {
|
||||||
|
operation: "process_stats_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalProcesses,
|
||||||
|
running: runningProcesses,
|
||||||
|
top: topProcesses,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/backend/ssh/widgets/system-collector.ts
Normal file
37
src/backend/ssh/widgets/system-collector.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import { statsLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
export async function collectSystemMetrics(client: Client): Promise<{
|
||||||
|
hostname: string | null;
|
||||||
|
kernel: string | null;
|
||||||
|
os: string | null;
|
||||||
|
}> {
|
||||||
|
let hostname: string | null = null;
|
||||||
|
let kernel: string | null = null;
|
||||||
|
let os: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hostnameOut = await execCommand(client, "hostname");
|
||||||
|
const kernelOut = await execCommand(client, "uname -r");
|
||||||
|
const osOut = await execCommand(
|
||||||
|
client,
|
||||||
|
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
|
||||||
|
);
|
||||||
|
|
||||||
|
hostname = hostnameOut.stdout.trim() || null;
|
||||||
|
kernel = kernelOut.stdout.trim() || null;
|
||||||
|
os = osOut.stdout.trim() || null;
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect system info", {
|
||||||
|
operation: "system_info_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostname,
|
||||||
|
kernel,
|
||||||
|
os,
|
||||||
|
};
|
||||||
|
}
|
||||||
35
src/backend/ssh/widgets/uptime-collector.ts
Normal file
35
src/backend/ssh/widgets/uptime-collector.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import { execCommand } from "./common-utils.js";
|
||||||
|
import { statsLogger } from "../../utils/logger.js";
|
||||||
|
|
||||||
|
export async function collectUptimeMetrics(client: Client): Promise<{
|
||||||
|
seconds: number | null;
|
||||||
|
formatted: string | null;
|
||||||
|
}> {
|
||||||
|
let uptimeSeconds: number | null = null;
|
||||||
|
let uptimeFormatted: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
||||||
|
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
||||||
|
if (uptimeParts.length >= 1) {
|
||||||
|
uptimeSeconds = Number(uptimeParts[0]);
|
||||||
|
if (Number.isFinite(uptimeSeconds)) {
|
||||||
|
const days = Math.floor(uptimeSeconds / 86400);
|
||||||
|
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||||
|
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statsLogger.debug("Failed to collect uptime", {
|
||||||
|
operation: "uptime_failed",
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seconds: uptimeSeconds,
|
||||||
|
formatted: uptimeFormatted,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ export type WidgetType =
|
|||||||
| "network"
|
| "network"
|
||||||
| "uptime"
|
| "uptime"
|
||||||
| "processes"
|
| "processes"
|
||||||
| "system";
|
| "system"
|
||||||
|
| "login_stats";
|
||||||
|
|
||||||
export interface StatsConfig {
|
export interface StatsConfig {
|
||||||
enabledWidgets: WidgetType[];
|
enabledWidgets: WidgetType[];
|
||||||
@@ -16,7 +17,15 @@ export interface StatsConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||||
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
enabledWidgets: [
|
||||||
|
"cpu",
|
||||||
|
"memory",
|
||||||
|
"disk",
|
||||||
|
"network",
|
||||||
|
"uptime",
|
||||||
|
"system",
|
||||||
|
"login_stats",
|
||||||
|
],
|
||||||
statusCheckEnabled: true,
|
statusCheckEnabled: true,
|
||||||
statusCheckInterval: 30,
|
statusCheckInterval: 30,
|
||||||
metricsEnabled: true,
|
metricsEnabled: true,
|
||||||
|
|||||||
@@ -265,9 +265,18 @@ export function HostManagerEditor({
|
|||||||
"uptime",
|
"uptime",
|
||||||
"processes",
|
"processes",
|
||||||
"system",
|
"system",
|
||||||
|
"login_stats",
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.default(["cpu", "memory", "disk", "network", "uptime", "system"]),
|
.default([
|
||||||
|
"cpu",
|
||||||
|
"memory",
|
||||||
|
"disk",
|
||||||
|
"network",
|
||||||
|
"uptime",
|
||||||
|
"system",
|
||||||
|
"login_stats",
|
||||||
|
]),
|
||||||
statusCheckEnabled: z.boolean().default(true),
|
statusCheckEnabled: z.boolean().default(true),
|
||||||
statusCheckInterval: z.number().min(5).max(3600).default(30),
|
statusCheckInterval: z.number().min(5).max(3600).default(30),
|
||||||
metricsEnabled: z.boolean().default(true),
|
metricsEnabled: z.boolean().default(true),
|
||||||
@@ -281,6 +290,7 @@ export function HostManagerEditor({
|
|||||||
"network",
|
"network",
|
||||||
"uptime",
|
"uptime",
|
||||||
"system",
|
"system",
|
||||||
|
"login_stats",
|
||||||
],
|
],
|
||||||
statusCheckEnabled: true,
|
statusCheckEnabled: true,
|
||||||
statusCheckInterval: 30,
|
statusCheckInterval: 30,
|
||||||
@@ -2611,6 +2621,7 @@ export function HostManagerEditor({
|
|||||||
"uptime",
|
"uptime",
|
||||||
"processes",
|
"processes",
|
||||||
"system",
|
"system",
|
||||||
|
"login_stats",
|
||||||
] as const
|
] as const
|
||||||
).map((widget) => (
|
).map((widget) => (
|
||||||
<div
|
<div
|
||||||
@@ -2650,6 +2661,8 @@ export function HostManagerEditor({
|
|||||||
t("serverStats.processes")}
|
t("serverStats.processes")}
|
||||||
{widget === "system" &&
|
{widget === "system" &&
|
||||||
t("serverStats.systemInfo")}
|
t("serverStats.systemInfo")}
|
||||||
|
{widget === "login_stats" &&
|
||||||
|
"SSH Login Statistics"}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
UptimeWidget,
|
UptimeWidget,
|
||||||
ProcessesWidget,
|
ProcessesWidget,
|
||||||
SystemWidget,
|
SystemWidget,
|
||||||
|
LoginStatsWidget,
|
||||||
} from "./widgets";
|
} from "./widgets";
|
||||||
|
|
||||||
interface HostConfig {
|
interface HostConfig {
|
||||||
@@ -137,6 +138,11 @@ export function Server({
|
|||||||
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "login_stats":
|
||||||
|
return (
|
||||||
|
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
Normal file
138
src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { UserCheck, UserX, MapPin, Activity } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface LoginRecord {
|
||||||
|
user: string;
|
||||||
|
ip: string;
|
||||||
|
time: string;
|
||||||
|
status: "success" | "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginStatsMetrics {
|
||||||
|
recentLogins: LoginRecord[];
|
||||||
|
failedLogins: LoginRecord[];
|
||||||
|
totalLogins: number;
|
||||||
|
uniqueIPs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServerMetrics {
|
||||||
|
login_stats?: LoginStatsMetrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginStatsWidgetProps {
|
||||||
|
metrics: ServerMetrics | null;
|
||||||
|
metricsHistory: ServerMetrics[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginStatsWidget({
|
||||||
|
metrics,
|
||||||
|
}: LoginStatsWidgetProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const loginStats = metrics?.login_stats;
|
||||||
|
const recentLogins = loginStats?.recentLogins || [];
|
||||||
|
const failedLogins = loginStats?.failedLogins || [];
|
||||||
|
const totalLogins = loginStats?.totalLogins || 0;
|
||||||
|
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||||
|
<UserCheck className="h-5 w-5 text-green-400" />
|
||||||
|
<h3 className="font-semibold text-lg text-white">
|
||||||
|
SSH Login Statistics
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||||
|
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
<span>Total Logins</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-green-400">{totalLogins}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
<span>Unique IPs</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-blue-400">{uniqueIPs}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<UserCheck className="h-4 w-4 text-green-400" />
|
||||||
|
<span className="text-sm font-semibold text-gray-300">
|
||||||
|
Recent Successful Logins
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{recentLogins.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-500 italic p-2">
|
||||||
|
No recent login data
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{recentLogins.slice(0, 5).map((login, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="text-xs bg-dark-bg-darker p-2 rounded border border-dark-border/30 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-green-400 font-mono truncate">
|
||||||
|
{login.user}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">from</span>
|
||||||
|
<span className="text-blue-400 font-mono truncate">
|
||||||
|
{login.ip}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||||
|
{new Date(login.time).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failedLogins.length > 0 && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<UserX className="h-4 w-4 text-red-400" />
|
||||||
|
<span className="text-sm font-semibold text-gray-300">
|
||||||
|
Recent Failed Attempts
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{failedLogins.slice(0, 3).map((login, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="text-xs bg-red-900/20 p-2 rounded border border-red-500/30 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-red-400 font-mono truncate">
|
||||||
|
{login.user}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500">from</span>
|
||||||
|
<span className="text-blue-400 font-mono truncate">
|
||||||
|
{login.ip}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||||
|
{new Date(login.time).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget";
|
|||||||
export { UptimeWidget } from "./UptimeWidget";
|
export { UptimeWidget } from "./UptimeWidget";
|
||||||
export { ProcessesWidget } from "./ProcessesWidget";
|
export { ProcessesWidget } from "./ProcessesWidget";
|
||||||
export { SystemWidget } from "./SystemWidget";
|
export { SystemWidget } from "./SystemWidget";
|
||||||
|
export { LoginStatsWidget } from "./LoginStatsWidget";
|
||||||
|
|||||||
Reference in New Issue
Block a user