mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 18:58:52 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,794 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
startMetricsPolling,
|
||||
stopMetricsPolling,
|
||||
submitMetricsTOTP,
|
||||
executeSnippet,
|
||||
logActivity,
|
||||
sendMetricsHeartbeat,
|
||||
getSSHHosts,
|
||||
type ServerMetrics,
|
||||
} from "@/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type WidgetType,
|
||||
type StatsConfig,
|
||||
DEFAULT_STATS_CONFIG,
|
||||
} from "@/types/stats-widgets.ts";
|
||||
import {
|
||||
CpuWidget,
|
||||
MemoryWidget,
|
||||
DiskWidget,
|
||||
NetworkWidget,
|
||||
UptimeWidget,
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
LoginStatsWidget,
|
||||
PortsWidget,
|
||||
FirewallWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { RefreshCw, Server } from "lucide-react";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
type ConnectionLogError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
};
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
function ServerStatsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
addLog,
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
|
||||
[],
|
||||
);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [totpVerified, setTotpVerified] = React.useState(false);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [hasConnectionError, setHasConnectionError] = React.useState(false);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof currentHostConfig.statsConfig === "string"
|
||||
? JSON.parse(currentHostConfig.statsConfig)
|
||||
: currentHostConfig.statsConfig;
|
||||
return { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
} catch (error) {
|
||||
console.error("Failed to parse statsConfig:", error);
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [currentHostConfig?.statsConfig]);
|
||||
|
||||
const enabledWidgets = statsConfig.enabledWidgets;
|
||||
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsPageVisible(!document.hidden);
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
await sendMetricsHeartbeat(viewerSessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send heartbeat:", error);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(heartbeatInterval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const logServerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("server_stats", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log server stats activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPSubmit = async (totpCode: string) => {
|
||||
if (!totpSessionId || !currentHostConfig) return;
|
||||
|
||||
try {
|
||||
const result = await submitMetricsTOTP(totpSessionId, totpCode);
|
||||
if (result.success) {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setShowStatsUI(true);
|
||||
setTotpVerified(true);
|
||||
if (result.viewerSessionId) {
|
||||
setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
} else {
|
||||
toast.error(t("serverStats.totpFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.totpFailed"));
|
||||
console.error("TOTP verification failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPCancel = async () => {
|
||||
setTotpRequired(false);
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
await stopMetricsPolling(currentHostConfig.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop metrics polling:", error);
|
||||
}
|
||||
}
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
case "cpu":
|
||||
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
case "memory":
|
||||
return (
|
||||
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "disk":
|
||||
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
case "network":
|
||||
return (
|
||||
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "uptime":
|
||||
return (
|
||||
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "processes":
|
||||
return (
|
||||
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "system":
|
||||
return (
|
||||
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "login_stats":
|
||||
return (
|
||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "ports":
|
||||
return (
|
||||
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "firewall":
|
||||
return (
|
||||
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!statusCheckEnabled || !currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const err = error as {
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (err?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = window.setInterval(
|
||||
fetchStatus,
|
||||
statsConfig.statusCheckInterval * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
currentHostConfig?.id,
|
||||
statusCheckEnabled,
|
||||
statsConfig.statusCheckInterval,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let pollingIntervalId: number | undefined;
|
||||
if (isActuallyVisible && !metrics) {
|
||||
setIsLoadingMetrics(true);
|
||||
setShowStatsUI(true);
|
||||
} else if (!isActuallyVisible) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
|
||||
const startMetrics = async () => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("serverStats.noneAuthNotSupported"));
|
||||
setIsLoadingMetrics(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasExistingMetrics = metrics !== null;
|
||||
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(true);
|
||||
}
|
||||
setShowStatsUI(true);
|
||||
setHasConnectionError(false);
|
||||
clearLogs();
|
||||
|
||||
try {
|
||||
if (!totpVerified) {
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (result?.connectionLogs) {
|
||||
result.connectionLogs.forEach((log) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
message: log.message,
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
setTotpPrompt(result.prompt || "Verification code");
|
||||
setIsLoadingMetrics(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.viewerSessionId) {
|
||||
setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
let retryCount = 0;
|
||||
let data = null;
|
||||
const maxRetries = 15;
|
||||
const retryDelay = 2000;
|
||||
|
||||
while (retryCount < maxRetries && !cancelled) {
|
||||
try {
|
||||
data = await getServerMetricsById(currentHostConfig.id);
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
retryCount++;
|
||||
if (retryCount === 1) {
|
||||
const initialDelay = totpVerified ? 3000 : 5000;
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
} else if (retryCount < maxRetries && !cancelled) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
if (!hasExistingMetrics) {
|
||||
setIsLoadingMetrics(false);
|
||||
logServerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
pollingIntervalId = window.setInterval(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled && data) {
|
||||
setMetrics(data);
|
||||
setMetricsHistory((prev) => {
|
||||
const newHistory = [...prev, data];
|
||||
return newHistory.slice(-20);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to fetch metrics:", error);
|
||||
}
|
||||
}
|
||||
}, statsConfig.metricsInterval * 1000);
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const logError = error as ConnectionLogError;
|
||||
console.error("Failed to start metrics polling:", error);
|
||||
setIsLoadingMetrics(false);
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (logError.connectionLogs) {
|
||||
logError.connectionLogs.forEach((log) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
message: log.message,
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("serverStats.connectionFailed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopMetrics = async () => {
|
||||
if (pollingIntervalId) {
|
||||
window.clearInterval(pollingIntervalId);
|
||||
pollingIntervalId = undefined;
|
||||
}
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
await stopMetricsPolling(
|
||||
currentHostConfig.id,
|
||||
viewerSessionId || undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop metrics polling:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimeout = setTimeout(() => {
|
||||
if (isActuallyVisible) {
|
||||
if (!hasConnectionError) {
|
||||
startMetrics();
|
||||
}
|
||||
} else {
|
||||
stopMetrics();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounceTimeout);
|
||||
if (pollingIntervalId) window.clearInterval(pollingIntervalId);
|
||||
if (currentHostConfig?.id) {
|
||||
stopMetricsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
currentHostConfig?.id,
|
||||
isActuallyVisible,
|
||||
metricsEnabled,
|
||||
statsConfig.metricsInterval,
|
||||
totpVerified,
|
||||
hasConnectionError,
|
||||
]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (data) setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative overflow-hidden flex flex-col"
|
||||
>
|
||||
<div
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
style={{
|
||||
visibility:
|
||||
hasConnectionError && isConnectionLogExpanded
|
||||
? "hidden"
|
||||
: "visible",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
|
||||
{!totpRequired && !isLoadingMetrics && (
|
||||
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg md:text-2xl font-bold">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 rounded-full ${serverStatus === "online" ? "bg-accent-brand" : "bg-destructive"}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{serverStatus === "online"
|
||||
? t("serverStats.online")
|
||||
: t("serverStats.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mr-3">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("serverStats.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("serverStats.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output?.substring(
|
||||
0,
|
||||
200,
|
||||
),
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("serverStats.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error || result.output,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="size-3 animate-spin mr-1" />
|
||||
{action.name}
|
||||
</>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8 mx-3" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("serverStats.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
!isLoadingMetrics &&
|
||||
!metrics &&
|
||||
serverStatus === "offline" && (
|
||||
<div className="flex-1 flex items-center justify-center py-20">
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="size-16 mx-auto mb-4" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metricsEnabled && showStatsUI && !isLoadingMetrics && metrics && (
|
||||
<div className="px-3 pt-3 pb-3 columns-1 md:columns-2 lg:columns-3 gap-3">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="break-inside-avoid mb-3">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsEnabled && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
|
||||
message={t("serverStats.connecting")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTOTPSubmit}
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isLoadingMetrics}
|
||||
isConnected={serverStatus === "online"}
|
||||
hasConnectionError={hasConnectionError}
|
||||
position={hasConnectionError ? "top" : "bottom"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerStats(props: ServerProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ServerStatsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface ServerStatsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerStats
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerStatsApp;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface CpuWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.cpu?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.cpu?.percent ?? null;
|
||||
const cores = metrics?.cpu?.cores ?? null;
|
||||
const load = metrics?.cpu?.load ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{cores !== null
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus")}
|
||||
</span>
|
||||
</div>
|
||||
{load && (
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{t("serverStats.loadAvg")}
|
||||
</span>
|
||||
<div className="text-xs font-mono">
|
||||
{load[0].toFixed(2)} {load[1].toFixed(2)}
|
||||
{load[2].toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface DiskWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.disk?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.disk?.percent ?? null;
|
||||
const usedHuman = metrics?.disk?.usedHuman ?? null;
|
||||
const totalHuman = metrics?.disk?.totalHuman ?? null;
|
||||
const availableHuman = metrics?.disk?.availableHuman ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.diskUsage")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{usedHuman && totalHuman ? `${usedHuman} / ${totalHuman}` : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("serverStats.available")}
|
||||
</span>
|
||||
<span className="font-mono">{availableHuman ?? "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import type {
|
||||
FirewallMetrics,
|
||||
FirewallChain,
|
||||
FirewallRule,
|
||||
} from "@/types/stats-widgets";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface FirewallWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function RuleRow({ rule }: { rule: FirewallRule }) {
|
||||
const { t } = useTranslation();
|
||||
const targetClass =
|
||||
rule.target.toUpperCase() === "ACCEPT"
|
||||
? "text-accent-brand"
|
||||
: rule.target.toUpperCase() === "DROP"
|
||||
? "text-destructive"
|
||||
: rule.target.toUpperCase() === "REJECT"
|
||||
? "text-yellow-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const src =
|
||||
rule.interface ??
|
||||
rule.state ??
|
||||
(rule.source === "0.0.0.0/0"
|
||||
? t("serverStats.firewall.anywhere")
|
||||
: rule.source);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs font-mono py-1 border-b border-border/50 last:border-0">
|
||||
<span className={`font-bold ${targetClass}`}>{rule.target}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{rule.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span>{rule.dport ?? "—"}</span>
|
||||
<span className="truncate text-muted-foreground" title={src}>
|
||||
{src}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainSection({ chain }: { chain: FirewallChain }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const policyClass =
|
||||
chain.policy.toUpperCase() === "ACCEPT"
|
||||
? "text-accent-brand"
|
||||
: chain.policy.toUpperCase() === "DROP"
|
||||
? "text-destructive"
|
||||
: "text-yellow-500";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 w-full py-1.5 hover:bg-muted/30 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<span className="text-xs font-bold">{chain.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
({t("serverStats.firewall.policy")}:{" "}
|
||||
<span className={policyClass}>{chain.policy}</span>)
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||||
{chain.rules.length} {t("serverStats.firewall.rules")}
|
||||
</span>
|
||||
</button>
|
||||
{open && chain.rules.length > 0 && (
|
||||
<div className="ml-5">
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border">
|
||||
<span>{t("serverStats.firewall.action")}</span>
|
||||
<span>{t("serverStats.firewall.protocol")}</span>
|
||||
<span>{t("serverStats.firewall.port")}</span>
|
||||
<span>{t("serverStats.firewall.source")}</span>
|
||||
</div>
|
||||
{chain.rules.map((rule, i) => (
|
||||
<RuleRow key={i} rule={rule} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const firewall = (metrics as ServerMetrics & { firewall?: FirewallMetrics })
|
||||
?.firewall;
|
||||
|
||||
const statusIcon =
|
||||
!firewall || firewall.type === "none" ? (
|
||||
<ShieldOff className="size-3.5 text-muted-foreground" />
|
||||
) : firewall.status === "active" ? (
|
||||
<ShieldCheck className="size-3.5 text-accent-brand" />
|
||||
) : (
|
||||
<Shield className="size-3.5 text-yellow-500" />
|
||||
);
|
||||
|
||||
const statusBadge =
|
||||
firewall?.status === "active" ? (
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-[10px] font-bold">
|
||||
<ShieldCheck className="size-3" /> ACTIVE
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-border text-muted-foreground text-[10px] font-bold">
|
||||
{t("serverStats.firewall.inactive").toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("serverStats.firewall.title")} icon={statusIcon}>
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("serverStats.firewall.title")}
|
||||
</span>
|
||||
{statusBadge}
|
||||
</div>
|
||||
{firewall?.type && firewall.type !== "none" && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{firewall.type}
|
||||
</span>
|
||||
)}
|
||||
{firewall && firewall.chains.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{firewall.chains.map((chain) => (
|
||||
<ChainSection key={chain.name} chain={chain} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverStats.firewall.noData")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
interface LoginStatsMetrics {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
interface LoginStatsWidgetProps {
|
||||
metrics: { login_stats?: LoginStatsMetrics } | null;
|
||||
metricsHistory: unknown[];
|
||||
}
|
||||
|
||||
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins ?? [];
|
||||
const failedLogins = loginStats?.failedLogins ?? [];
|
||||
|
||||
const allLogins = [
|
||||
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
|
||||
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.loginStats")}
|
||||
icon={<UserCheck className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{allLogins.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic py-2">
|
||||
{t("serverStats.noRecentLoginData")}
|
||||
</span>
|
||||
) : (
|
||||
allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
|
||||
>
|
||||
{login.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface MemoryWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.memory?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.memory?.percent ?? null;
|
||||
const usedGiB = metrics?.memory?.usedGiB ?? null;
|
||||
const totalGiB = metrics?.memory?.totalGiB ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{usedGiB !== null && totalGiB !== null
|
||||
? `${usedGiB.toFixed(1)} / ${totalGiB.toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col p-2 bg-muted/30 border border-border">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("serverStats.swap")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">N/A</span>
|
||||
</div>
|
||||
<div className="flex flex-col p-2 bg-muted/30 border border-border">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("serverStats.free")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{usedGiB !== null && totalGiB !== null
|
||||
? `${(totalGiB - usedGiB).toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Network, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface NetworkWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithNetwork = metrics as ServerMetrics & {
|
||||
network?: {
|
||||
interfaces?: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
ip: string;
|
||||
rx?: string;
|
||||
tx?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{interfaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
|
||||
<WifiOff className="size-6 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("serverStats.noInterfacesFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>{iface.ip}</span>
|
||||
{(iface.rx || iface.tx) && (
|
||||
<span>
|
||||
↓ {iface.rx ?? "—"} / ↑ {iface.tx ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Unplug } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface PortsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function PortRow({ port }: { port: ListeningPort }) {
|
||||
const formatAddress = (addr: string) =>
|
||||
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0">
|
||||
<span className="text-accent-brand font-bold">{port.localPort}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{port.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-semibold truncate">
|
||||
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
|
||||
</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{formatAddress(port.localAddress)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const portsData = (metrics as ServerMetrics & { ports?: PortsMetrics })
|
||||
?.ports;
|
||||
const ports = portsData?.ports ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.ports.title")}
|
||||
icon={<Unplug className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0">
|
||||
<span>{t("serverStats.ports.port")}</span>
|
||||
<span>{t("serverStats.ports.protocol")}</span>
|
||||
<span>{t("serverStats.ports.process")}</span>
|
||||
<span className="text-right">{t("serverStats.ports.address")}</span>
|
||||
</div>
|
||||
{ports.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic py-2">
|
||||
{t("serverStats.ports.noData")}
|
||||
</span>
|
||||
) : (
|
||||
ports.map((port, i) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${i}`}
|
||||
port={port}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { List, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface ProcessesWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithProcesses = metrics as ServerMetrics & {
|
||||
processes?: {
|
||||
total?: number;
|
||||
running?: number;
|
||||
top?: Array<{
|
||||
pid: number;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
command: string;
|
||||
user: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const processes = metricsWithProcesses?.processes;
|
||||
const topProcesses = processes?.top ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.processes")}
|
||||
icon={<List className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase tracking-wider pb-1 border-b border-border min-w-0">
|
||||
<span>PID</span>
|
||||
<span>CPU</span>
|
||||
<span>MEM</span>
|
||||
<span>CMD</span>
|
||||
</div>
|
||||
{topProcesses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
|
||||
<Activity className="size-6 opacity-40" />
|
||||
<span className="text-xs">{t("serverStats.noProcessesFound")}</span>
|
||||
</div>
|
||||
) : (
|
||||
topProcesses.map((proc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{proc.pid}</span>
|
||||
<span className="text-accent-brand font-bold">{proc.cpu}%</span>
|
||||
<span>{proc.mem}%</span>
|
||||
<span className="truncate font-semibold" title={proc.command}>
|
||||
{proc.command.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface SystemWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function SystemWidget({ metrics }: SystemWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithSystem = metrics as ServerMetrics & {
|
||||
system?: { hostname?: string; os?: string; kernel?: string; arch?: string };
|
||||
uptime?: { formatted?: string };
|
||||
};
|
||||
const system = metricsWithSystem?.system;
|
||||
const uptime = metricsWithSystem?.uptime;
|
||||
|
||||
const rows = [
|
||||
{ label: t("serverStats.hostname"), value: system?.hostname },
|
||||
{ label: t("serverStats.operatingSystem"), value: system?.os },
|
||||
{ label: t("serverStats.kernel"), value: system?.kernel },
|
||||
{ label: t("serverStats.architecture"), value: system?.arch },
|
||||
{ label: t("serverStats.uptime"), value: uptime?.formatted },
|
||||
].filter((r) => r.value);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.systemInfo")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-y-3 py-2">
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-semibold truncate">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface UptimeWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithUptime = metrics as ServerMetrics & {
|
||||
uptime?: { formatted?: string; seconds?: number };
|
||||
};
|
||||
const uptime = metricsWithUptime?.uptime;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.uptime")}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{uptime?.formatted ?? "N/A"}
|
||||
</span>
|
||||
{uptime?.seconds && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{Math.floor(uptime.seconds).toLocaleString()}{" "}
|
||||
{t("serverStats.seconds")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { CpuWidget } from "./CpuWidget.tsx";
|
||||
export { MemoryWidget } from "./MemoryWidget.tsx";
|
||||
export { DiskWidget } from "./DiskWidget.tsx";
|
||||
export { NetworkWidget } from "./NetworkWidget.tsx";
|
||||
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";
|
||||
Reference in New Issue
Block a user