feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-13 01:29:43 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
@@ -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)}&nbsp;&nbsp;{load[1].toFixed(2)}&nbsp;&nbsp;
{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";