diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index ce8f5deb..34582d6d 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; 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 { AuthManager } from "../utils/auth-manager.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 { client: Client; @@ -924,59 +932,6 @@ async function withSshConnection( } } -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<{ cpu: { percent: number | null; @@ -1039,318 +994,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ return requestQueue.queueRequest(host.id, async () => { try { return await withSshConnection(host, async (client) => { - let cpuPercent: number | null = null; - let cores: number | null = null; - let loadTriplet: [number, number, number] | null = null; + const cpu = await collectCpuMetrics(client); + const memory = await collectMemoryMetrics(client); + 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 { - 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; + login_stats = await collectLoginStats(client); } catch (e) { - cpuPercent = null; - cores = null; - 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(); - 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", + statsLogger.debug("Failed to collect login stats", { + operation: "login_stats_failed", error: e instanceof Error ? e.message : String(e), }); } const result = { - cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, - memory: { - percent: toFixedNum(memPercent, 0), - usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, - totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, - }, - disk: { - percent: toFixedNum(diskPercent, 0), - usedHuman, - totalHuman, - availableHuman, - }, - network: { - interfaces, - }, - uptime: { - seconds: uptimeSeconds, - formatted: uptimeFormatted, - }, - processes: { - total: totalProcesses, - running: runningProcesses, - top: topProcesses, - }, - system: { - hostname, - kernel, - os, - }, + cpu, + memory, + disk, + network, + uptime, + processes, + system, + login_stats, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/ssh/widgets/common-utils.ts new file mode 100644 index 00000000..802c8571 --- /dev/null +++ b/src/backend/ssh/widgets/common-utils.ts @@ -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); +} diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/ssh/widgets/cpu-collector.ts new file mode 100644 index 00000000..359ae6ad --- /dev/null +++ b/src/backend/ssh/widgets/cpu-collector.ts @@ -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, + }; +} diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/ssh/widgets/disk-collector.ts new file mode 100644 index 00000000..b221cee2 --- /dev/null +++ b/src/backend/ssh/widgets/disk-collector.ts @@ -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, + }; +} diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts new file mode 100644 index 00000000..5147b146 --- /dev/null +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -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 { + const recentLogins: LoginRecord[] = []; + const failedLogins: LoginRecord[] = []; + const ipSet = new Set(); + + 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, + }; +} diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/ssh/widgets/memory-collector.ts new file mode 100644 index 00000000..3dce5c64 --- /dev/null +++ b/src/backend/ssh/widgets/memory-collector.ts @@ -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, + }; +} diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts new file mode 100644 index 00000000..bd3a3bd9 --- /dev/null +++ b/src/backend/ssh/widgets/network-collector.ts @@ -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(); + 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 }; +} diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts new file mode 100644 index 00000000..02f3ea11 --- /dev/null +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -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, + }; +} diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts new file mode 100644 index 00000000..e62c3ed0 --- /dev/null +++ b/src/backend/ssh/widgets/system-collector.ts @@ -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, + }; +} diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts new file mode 100644 index 00000000..87e8dfcc --- /dev/null +++ b/src/backend/ssh/widgets/uptime-collector.ts @@ -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, + }; +} diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index eb450aa7..f7040ae4 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -5,7 +5,8 @@ export type WidgetType = | "network" | "uptime" | "processes" - | "system"; + | "system" + | "login_stats"; export interface StatsConfig { enabledWidgets: WidgetType[]; @@ -16,7 +17,15 @@ export interface 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, statusCheckInterval: 30, metricsEnabled: true, diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index ad48b4a0..bbc1ad04 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -265,9 +265,18 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ]), ) - .default(["cpu", "memory", "disk", "network", "uptime", "system"]), + .default([ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), metricsEnabled: z.boolean().default(true), @@ -281,6 +290,7 @@ export function HostManagerEditor({ "network", "uptime", "system", + "login_stats", ], statusCheckEnabled: true, statusCheckInterval: 30, @@ -2611,6 +2621,7 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ] as const ).map((widget) => (
))} diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 09b37dd4..21879e0f 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -25,6 +25,7 @@ import { UptimeWidget, ProcessesWidget, SystemWidget, + LoginStatsWidget, } from "./widgets"; interface HostConfig { @@ -137,6 +138,11 @@ export function Server({ ); + case "login_stats": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx new file mode 100644 index 00000000..61940581 --- /dev/null +++ b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx @@ -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 ( +
+
+ +

+ SSH Login Statistics +

+
+ +
+
+
+
+ + Total Logins +
+
{totalLogins}
+
+
+
+ + Unique IPs +
+
{uniqueIPs}
+
+
+ +
+
+
+ + + Recent Successful Logins + +
+ {recentLogins.length === 0 ? ( +
+ No recent login data +
+ ) : ( +
+ {recentLogins.slice(0, 5).map((login, idx) => ( +
+
+ + {login.user} + + from + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+ )} +
+ + {failedLogins.length > 0 && ( +
+
+ + + Recent Failed Attempts + +
+
+ {failedLogins.slice(0, 3).map((login, idx) => ( +
+
+ + {login.user} + + from + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/server/widgets/index.ts b/src/ui/desktop/apps/server/widgets/index.ts index 2d227299..b72f8a11 100644 --- a/src/ui/desktop/apps/server/widgets/index.ts +++ b/src/ui/desktop/apps/server/widgets/index.ts @@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget"; export { UptimeWidget } from "./UptimeWidget"; export { ProcessesWidget } from "./ProcessesWidget"; export { SystemWidget } from "./SystemWidget"; +export { LoginStatsWidget } from "./LoginStatsWidget";