Merge branch 'dev-1.10.1' into feat/ports-widget

This commit is contained in:
Luke Gustafson
2026-01-12 02:45:59 -05:00
committed by GitHub
56 changed files with 68494 additions and 145 deletions

View File

@@ -255,7 +255,7 @@ export function ContainerCard({
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
<CardTitle className="text-base font-semibold truncate flex-1 min-w-0">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}

View File

@@ -332,11 +332,21 @@ export function FileViewer({
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
return `data:image/svg+xml;base64,${content}`;
}
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
return `data:image/*;base64,${content}`;
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024;

View File

@@ -34,6 +34,7 @@ import {
SystemWidget,
LoginStatsWidget,
PortsWidget,
FirewallWidget,
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
@@ -269,6 +270,10 @@ export function ServerStats({
case "ports":
return (
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
case "firewall":
return (
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:

View File

@@ -0,0 +1,213 @@
import React from "react";
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "@/types/stats-widgets";
interface FirewallWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function RuleRow({ rule }: { rule: FirewallRule }) {
const { t } = useTranslation();
const getTargetStyle = (target: string) => {
switch (target.toUpperCase()) {
case "ACCEPT":
return "text-green-400";
case "DROP":
return "text-red-400";
case "REJECT":
return "text-orange-400";
default:
return "text-muted-foreground";
}
};
const getTargetLabel = (target: string) => {
switch (target.toUpperCase()) {
case "ACCEPT":
return t("serverStats.firewall.accept");
case "DROP":
return t("serverStats.firewall.drop");
case "REJECT":
return t("serverStats.firewall.reject");
default:
return target;
}
};
const formatSource = () => {
if (rule.interface) {
return rule.interface;
}
if (rule.state) {
return rule.state;
}
if (rule.source === "0.0.0.0/0") {
return t("serverStats.firewall.anywhere");
}
return rule.source;
};
return (
<div className="grid grid-cols-4 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
<div className={`font-medium ${getTargetStyle(rule.target)}`}>
{getTargetLabel(rule.target)}
</div>
<div className="text-foreground-subtle font-mono">
{rule.protocol.toUpperCase()}
</div>
<div className="text-foreground-subtle font-mono">
{rule.dport || "-"}
</div>
<div className="text-foreground-subtle truncate" title={formatSource()}>
{formatSource()}
</div>
</div>
);
}
function ChainSection({ chain }: { chain: FirewallChain }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = React.useState(true);
const getPolicyStyle = (policy: string) => {
switch (policy.toUpperCase()) {
case "ACCEPT":
return "text-green-400";
case "DROP":
return "text-red-400";
case "REJECT":
return "text-orange-400";
default:
return "text-muted-foreground";
}
};
return (
<div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 w-full py-1.5 hover:bg-canvas/30 rounded px-1 -mx-1 text-left"
>
<ChevronDown
className={`h-3 w-3 text-muted-foreground transition-transform ${
isOpen ? "" : "-rotate-90"
}`}
/>
<span className="text-sm font-medium text-foreground">
{chain.name}
</span>
<span className="text-xs text-muted-foreground">
({t("serverStats.firewall.policy")}:{" "}
<span className={getPolicyStyle(chain.policy)}>{chain.policy}</span>)
</span>
<span className="text-xs text-muted-foreground ml-auto">
{chain.rules.length} {t("serverStats.firewall.rules")}
</span>
</button>
{isOpen && (
<>
{chain.rules.length > 0 ? (
<div className="mt-2 ml-5">
<div className="grid grid-cols-4 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1">
<div>{t("serverStats.firewall.action")}</div>
<div>{t("serverStats.firewall.protocol")}</div>
<div>{t("serverStats.firewall.port")}</div>
<div>{t("serverStats.firewall.source")}</div>
</div>
<div className="max-h-32 overflow-y-auto thin-scrollbar">
{chain.rules.map((rule, idx) => (
<RuleRow key={idx} rule={rule} />
))}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground ml-5 mt-1">
{t("serverStats.firewall.noRules")}
</div>
)}
</>
)}
</div>
);
}
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
const { t } = useTranslation();
const firewall = (
metrics as ServerMetrics & { firewall?: FirewallMetrics }
)?.firewall;
const getStatusIcon = () => {
if (!firewall || firewall.type === "none") {
return <ShieldOff className="h-5 w-5 text-muted-foreground" />;
}
if (firewall.status === "active") {
return <ShieldCheck className="h-5 w-5 text-green-400" />;
}
return <Shield className="h-5 w-5 text-orange-400" />;
};
const getStatusText = () => {
if (!firewall || firewall.type === "none") {
return t("serverStats.firewall.notDetected");
}
if (firewall.status === "active") {
return t("serverStats.firewall.active");
}
return t("serverStats.firewall.inactive");
};
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">
{getStatusIcon()}
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.firewall.title")}
</h3>
{firewall && firewall.type !== "none" && (
<span className="text-xs text-muted-foreground ml-auto bg-canvas/50 px-2 py-0.5 rounded">
{firewall.type}
</span>
)}
</div>
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
<span
className={`text-sm font-medium ${
firewall?.status === "active"
? "text-green-400"
: firewall?.status === "inactive"
? "text-orange-400"
: "text-muted-foreground"
}`}
>
{getStatusText()}
</span>
</div>
{firewall && firewall.chains.length > 0 ? (
<div className="flex-1 overflow-y-auto thin-scrollbar space-y-2">
{firewall.chains.map((chain) => (
<ChainSection key={chain.name} chain={chain} />
))}
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">
{t("serverStats.firewall.noData")}
</p>
</div>
)}
</div>
);
}

View File

@@ -7,3 +7,4 @@ 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

@@ -618,15 +618,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
if (
@@ -1387,7 +1387,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const selectedCommand =
autocompleteSuggestionsRef.current[
autocompleteSelectedIndexRef.current
autocompleteSelectedIndexRef.current
];
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
@@ -1548,7 +1548,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
// Note: Using hostConfig.id instead of hostConfig object to prevent
// unnecessary reconnections when host properties are updated.
// Only reconnect when switching to a different host.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return;