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 { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||||
import { collectLoginStats } from "./widgets/login-stats-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 { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||||
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
import { createSocks5Connection } from "../utils/socks5-helper.js";
|
||||||
|
|
||||||
@@ -1783,6 +1784,25 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
login_stats = await collectLoginStats(client);
|
login_stats = await collectLoginStats(client);
|
||||||
} catch (e) {}
|
} 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: {
|
let firewall: {
|
||||||
type: "iptables" | "nftables" | "none";
|
type: "iptables" | "nftables" | "none";
|
||||||
status: "active" | "inactive" | "unknown";
|
status: "active" | "inactive" | "unknown";
|
||||||
@@ -1825,6 +1845,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
|||||||
processes,
|
processes,
|
||||||
system,
|
system,
|
||||||
login_stats,
|
login_stats,
|
||||||
|
ports,
|
||||||
firewall,
|
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",
|
"quickActionSuccess": "{{name}} completed successfully",
|
||||||
"quickActionFailed": "{{name}} failed",
|
"quickActionFailed": "{{name}} failed",
|
||||||
"quickActionError": "Failed to execute {{name}}",
|
"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": {
|
"firewall": {
|
||||||
"title": "Firewall",
|
"title": "Firewall",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ export type WidgetType =
|
|||||||
| "processes"
|
| "processes"
|
||||||
| "system"
|
| "system"
|
||||||
| "login_stats"
|
| "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";
|
| "firewall";
|
||||||
|
|
||||||
export interface FirewallRule {
|
export interface FirewallRule {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
ProcessesWidget,
|
ProcessesWidget,
|
||||||
SystemWidget,
|
SystemWidget,
|
||||||
LoginStatsWidget,
|
LoginStatsWidget,
|
||||||
|
PortsWidget,
|
||||||
FirewallWidget,
|
FirewallWidget,
|
||||||
} from "./widgets";
|
} from "./widgets";
|
||||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||||
@@ -266,6 +267,10 @@ export function ServerStats({
|
|||||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "ports":
|
||||||
|
return (
|
||||||
|
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||||
|
|
||||||
case "firewall":
|
case "firewall":
|
||||||
return (
|
return (
|
||||||
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
|
<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 { ProcessesWidget } from "./ProcessesWidget.tsx";
|
||||||
export { SystemWidget } from "./SystemWidget.tsx";
|
export { SystemWidget } from "./SystemWidget.tsx";
|
||||||
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
|
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
|
||||||
|
export { PortsWidget } from "./PortsWidget.tsx";
|
||||||
export { FirewallWidget } from "./FirewallWidget.tsx";
|
export { FirewallWidget } from "./FirewallWidget.tsx";
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ export function HostManagerEditor({
|
|||||||
"processes",
|
"processes",
|
||||||
"system",
|
"system",
|
||||||
"login_stats",
|
"login_stats",
|
||||||
|
"ports",
|
||||||
"firewall",
|
"firewall",
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -328,6 +329,7 @@ export function HostManagerEditor({
|
|||||||
"uptime",
|
"uptime",
|
||||||
"system",
|
"system",
|
||||||
"login_stats",
|
"login_stats",
|
||||||
|
"ports",
|
||||||
"firewall",
|
"firewall",
|
||||||
]),
|
]),
|
||||||
statusCheckEnabled: z.boolean().default(true),
|
statusCheckEnabled: z.boolean().default(true),
|
||||||
@@ -344,6 +346,7 @@ export function HostManagerEditor({
|
|||||||
"uptime",
|
"uptime",
|
||||||
"system",
|
"system",
|
||||||
"login_stats",
|
"login_stats",
|
||||||
|
"ports",
|
||||||
"firewall",
|
"firewall",
|
||||||
],
|
],
|
||||||
statusCheckEnabled: true,
|
statusCheckEnabled: true,
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export function HostStatisticsTab({
|
|||||||
"processes",
|
"processes",
|
||||||
"system",
|
"system",
|
||||||
"login_stats",
|
"login_stats",
|
||||||
|
"ports",
|
||||||
"firewall",
|
"firewall",
|
||||||
] as const
|
] as const
|
||||||
).map((widget) => (
|
).map((widget) => (
|
||||||
@@ -267,6 +268,8 @@ export function HostStatisticsTab({
|
|||||||
{widget === "system" && t("serverStats.systemInfo")}
|
{widget === "system" && t("serverStats.systemInfo")}
|
||||||
{widget === "login_stats" &&
|
{widget === "login_stats" &&
|
||||||
t("serverStats.loginStats")}
|
t("serverStats.loginStats")}
|
||||||
|
{widget === "ports" &&
|
||||||
|
t("serverStats.ports.title")}
|
||||||
{widget === "firewall" &&
|
{widget === "firewall" &&
|
||||||
t("serverStats.firewall.title")}
|
t("serverStats.firewall.title")}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user