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:
ZacharyZcR
2026-01-12 15:46:05 +08:00
committed by GitHub
parent d821373b15
commit 2e3f7e10c7
9 changed files with 308 additions and 0 deletions

View File

@@ -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,
};

View 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: [],
};
}
}

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>