feat: add listening ports widget for server stats (#483)
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
This commit was merged in pull request #483.
This commit is contained in:
@@ -19,6 +19,7 @@ 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";
|
||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||
|
||||
@@ -1783,6 +1784,25 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
login_stats = await collectLoginStats(client);
|
||||
} catch (e) {}
|
||||
|
||||
let ports: {
|
||||
source: "ss" | "netstat" | "none";
|
||||
ports: Array<{
|
||||
protocol: "tcp" | "udp";
|
||||
localAddress: string;
|
||||
localPort: number;
|
||||
state?: string;
|
||||
pid?: number;
|
||||
process?: string;
|
||||
}>;
|
||||
} = {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
try {
|
||||
ports = await collectPortsMetrics(client);
|
||||
} catch (e) {
|
||||
statsLogger.debug("Failed to collect ports metrics", {
|
||||
operation: "ports_metrics_failed",
|
||||
let firewall: {
|
||||
type: "iptables" | "nftables" | "none";
|
||||
status: "active" | "inactive" | "unknown";
|
||||
@@ -1825,6 +1845,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
processes,
|
||||
system,
|
||||
login_stats,
|
||||
ports,
|
||||
firewall,
|
||||
};
|
||||
|
||||
|
||||
155
src/backend/ssh/widgets/ports-collector.ts
Normal file
155
src/backend/ssh/widgets/ports-collector.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import type { PortsMetrics, ListeningPort } from "../../../types/stats-widgets.js";
|
||||
|
||||
function parseSsOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n").slice(1);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const protocol = parts[0]?.toLowerCase();
|
||||
if (protocol !== "tcp" && protocol !== "udp") continue;
|
||||
|
||||
const state = parts[1];
|
||||
const localAddr = parts[4];
|
||||
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol: protocol as "tcp" | "udp",
|
||||
localAddress: address.replace(/^\[|\]$/g, ""),
|
||||
localPort: port,
|
||||
state: protocol === "tcp" ? state : undefined,
|
||||
};
|
||||
|
||||
const processInfo = parts[6];
|
||||
if (processInfo && processInfo.startsWith("users:")) {
|
||||
const pidMatch = processInfo.match(/pid=(\d+)/);
|
||||
const nameMatch = processInfo.match(/\("([^"]+)"/);
|
||||
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
|
||||
if (nameMatch) portEntry.process = nameMatch[1];
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
function parseNetstatOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const proto = parts[0]?.toLowerCase();
|
||||
if (!proto) continue;
|
||||
|
||||
let protocol: "tcp" | "udp";
|
||||
if (proto.startsWith("tcp")) {
|
||||
protocol = "tcp";
|
||||
} else if (proto.startsWith("udp")) {
|
||||
protocol = "udp";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localAddr = parts[3];
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol,
|
||||
localAddress: address,
|
||||
localPort: port,
|
||||
};
|
||||
|
||||
if (protocol === "tcp" && parts.length >= 6) {
|
||||
portEntry.state = parts[5];
|
||||
}
|
||||
|
||||
const pidProgram = parts[parts.length - 1];
|
||||
if (pidProgram && pidProgram.includes("/")) {
|
||||
const [pidStr, process] = pidProgram.split("/");
|
||||
const pid = parseInt(pidStr, 10);
|
||||
if (!isNaN(pid)) portEntry.pid = pid;
|
||||
if (process) portEntry.process = process;
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
export async function collectPortsMetrics(
|
||||
client: Client,
|
||||
): Promise<PortsMetrics> {
|
||||
try {
|
||||
const ssResult = await execCommand(
|
||||
client,
|
||||
"ss -tulnp 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
|
||||
const ports = parseSsOutput(ssResult.stdout);
|
||||
return {
|
||||
source: "ss",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
const netstatResult = await execCommand(
|
||||
client,
|
||||
"netstat -tulnp 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
|
||||
const ports = parseNetstatOutput(netstatResult.stdout);
|
||||
return {
|
||||
source: "netstat",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1732,6 +1732,14 @@
|
||||
"quickActionSuccess": "{{name}} completed successfully",
|
||||
"quickActionFailed": "{{name}} failed",
|
||||
"quickActionError": "Failed to execute {{name}}",
|
||||
"ports": {
|
||||
"title": "Listening Ports",
|
||||
"protocol": "Protocol",
|
||||
"port": "Port",
|
||||
"address": "Address",
|
||||
"state": "State",
|
||||
"process": "Process",
|
||||
"noData": "No listening ports data"
|
||||
"firewall": {
|
||||
"title": "Firewall",
|
||||
"active": "Active",
|
||||
|
||||
@@ -7,6 +7,20 @@ export type WidgetType =
|
||||
| "processes"
|
||||
| "system"
|
||||
| "login_stats"
|
||||
| "ports";
|
||||
|
||||
export interface ListeningPort {
|
||||
protocol: "tcp" | "udp";
|
||||
localAddress: string;
|
||||
localPort: number;
|
||||
state?: string;
|
||||
pid?: number;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
export interface PortsMetrics {
|
||||
source: "ss" | "netstat" | "none";
|
||||
ports: ListeningPort[];
|
||||
| "firewall";
|
||||
|
||||
export interface FirewallRule {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
LoginStatsWidget,
|
||||
PortsWidget,
|
||||
FirewallWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
@@ -266,6 +267,10 @@ export function ServerStats({
|
||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "ports":
|
||||
return (
|
||||
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
|
||||
case "firewall":
|
||||
return (
|
||||
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
|
||||
|
||||
interface PortsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function PortRow({ port }: { port: ListeningPort }) {
|
||||
const formatAddress = (addr: string) => {
|
||||
if (addr === "0.0.0.0" || addr === "*" || addr === "::") {
|
||||
return "*";
|
||||
}
|
||||
return addr;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
|
||||
<div className="font-mono text-foreground-subtle">
|
||||
{port.protocol.toUpperCase()}
|
||||
</div>
|
||||
<div className="font-mono text-foreground">
|
||||
{port.localPort}
|
||||
</div>
|
||||
<div className="font-mono text-foreground-subtle truncate" title={formatAddress(port.localAddress)}>
|
||||
{formatAddress(port.localAddress)}
|
||||
</div>
|
||||
<div className="text-foreground-subtle">
|
||||
{port.state || "-"}
|
||||
</div>
|
||||
<div className="text-foreground-subtle truncate" title={port.process || "-"}>
|
||||
{port.process || (port.pid ? `PID:${port.pid}` : "-")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const portsData = (
|
||||
metrics as ServerMetrics & { ports?: PortsMetrics }
|
||||
)?.ports;
|
||||
|
||||
const tcpPorts = portsData?.ports.filter(p => p.protocol === "tcp") || [];
|
||||
const udpPorts = portsData?.ports.filter(p => p.protocol === "udp") || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-cyan-400" />
|
||||
<h3 className="font-semibold text-lg text-foreground">
|
||||
{t("serverStats.ports.title")}
|
||||
</h3>
|
||||
{portsData && portsData.source !== "none" && (
|
||||
<span className="text-xs text-muted-foreground ml-auto bg-canvas/50 px-2 py-0.5 rounded">
|
||||
{portsData.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3 flex-shrink-0 text-sm">
|
||||
<span className="text-foreground-subtle">
|
||||
TCP: <span className="text-cyan-400 font-medium">{tcpPorts.length}</span>
|
||||
</span>
|
||||
<span className="text-foreground-subtle">
|
||||
UDP: <span className="text-cyan-400 font-medium">{udpPorts.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{portsData && portsData.ports.length > 0 ? (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="grid grid-cols-5 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1 flex-shrink-0">
|
||||
<div>{t("serverStats.ports.protocol")}</div>
|
||||
<div>{t("serverStats.ports.port")}</div>
|
||||
<div>{t("serverStats.ports.address")}</div>
|
||||
<div>{t("serverStats.ports.state")}</div>
|
||||
<div>{t("serverStats.ports.process")}</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto thin-scrollbar">
|
||||
{portsData.ports.map((port, idx) => (
|
||||
<PortRow key={`${port.protocol}-${port.localPort}-${idx}`} port={port} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverStats.ports.noData")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export { UptimeWidget } from "./UptimeWidget.tsx";
|
||||
export { ProcessesWidget } from "./ProcessesWidget.tsx";
|
||||
export { SystemWidget } from "./SystemWidget.tsx";
|
||||
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
|
||||
export { PortsWidget } from "./PortsWidget.tsx";
|
||||
export { FirewallWidget } from "./FirewallWidget.tsx";
|
||||
|
||||
@@ -317,6 +317,7 @@ export function HostManagerEditor({
|
||||
"processes",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
]),
|
||||
)
|
||||
@@ -328,6 +329,7 @@ export function HostManagerEditor({
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
]),
|
||||
statusCheckEnabled: z.boolean().default(true),
|
||||
@@ -344,6 +346,7 @@ export function HostManagerEditor({
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
|
||||
@@ -239,6 +239,7 @@ export function HostStatisticsTab({
|
||||
"processes",
|
||||
"system",
|
||||
"login_stats",
|
||||
"ports",
|
||||
"firewall",
|
||||
] as const
|
||||
).map((widget) => (
|
||||
@@ -267,6 +268,8 @@ export function HostStatisticsTab({
|
||||
{widget === "system" && t("serverStats.systemInfo")}
|
||||
{widget === "login_stats" &&
|
||||
t("serverStats.loginStats")}
|
||||
{widget === "ports" &&
|
||||
t("serverStats.ports.title")}
|
||||
{widget === "firewall" &&
|
||||
t("serverStats.firewall.title")}
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user