Merge branch 'dev-1.10.1' into feat/ports-widget
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user