mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Auth } from "@/auth/LoginPage.tsx";
|
||||
import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
getUserInfo,
|
||||
getDatabaseHealth,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
isCurrentAuthInvalidationError,
|
||||
type RecentActivityItem,
|
||||
} from "@/main-axios.ts";
|
||||
import { useSidebar } from "@/components/sidebar.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { useTabs } from "@/shell/TabContext.tsx";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Settings as SettingsIcon } from "lucide-react";
|
||||
import { ServerOverviewCard } from "@/dashboard/cards/ServerOverviewCard";
|
||||
import { RecentActivityCard } from "@/dashboard/cards/RecentActivityCard";
|
||||
import { QuickActionsCard } from "@/dashboard/cards/QuickActionsCard";
|
||||
import { ServerStatsCard } from "@/dashboard/cards/ServerStatsCard";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
import { useDashboardPreferences } from "@/dashboard/hooks/useDashboardPreferences";
|
||||
import { DashboardSettingsDialog } from "@/dashboard/components/DashboardSettingsDialog";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader";
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
initialDbError?: string | null;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
initialDbError = null,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [, setDbError] = useState<string | null>(initialDbError);
|
||||
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
const [totalCredentials, setTotalCredentials] = useState<number>(0);
|
||||
const [updateCheckDisabled, setUpdateCheckDisabled] = useState<boolean>(
|
||||
localStorage.getItem("disableUpdateCheck") === "true",
|
||||
);
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [recentActivityLoading, setRecentActivityLoading] =
|
||||
useState<boolean>(true);
|
||||
const [serverStats, setServerStats] = useState<
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [viewerSessions, setViewerSessions] = useState<Map<number, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
const {
|
||||
layout,
|
||||
loading: preferencesLoading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
} = useDashboardPreferences(loggedIn);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mainWidthPct = layout?.mainWidthPct ?? 68;
|
||||
|
||||
const handleDividerMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startPct = mainWidthPct;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const totalW = containerRef.current.getBoundingClientRect().width;
|
||||
const delta = ev.clientX - startX;
|
||||
const newPct = Math.min(
|
||||
85,
|
||||
Math.max(30, startPct + (delta / totalW) * 100),
|
||||
);
|
||||
if (layout) updateLayout({ ...layout, mainWidthPct: newPct });
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[mainWidthPct, layout, updateLayout],
|
||||
);
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch {
|
||||
// Sidebar context is not available on every dashboard mount path.
|
||||
}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const rightMarginPx = 17;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isCurrentAuthInvalidationError(err)) {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const uptimeInfo = await getUptime();
|
||||
setUptime(uptimeInfo.formatted);
|
||||
|
||||
const updateDisabled =
|
||||
localStorage.getItem("disableUpdateCheck") === "true";
|
||||
setUpdateCheckDisabled(updateDisabled);
|
||||
if (!updateDisabled) {
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
if (
|
||||
versionInfo.status === "up_to_date" ||
|
||||
versionInfo.status === "requires_update" ||
|
||||
versionInfo.status === "beta"
|
||||
) {
|
||||
setVersionStatus(versionInfo.status);
|
||||
}
|
||||
} else {
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
setDbHealth("healthy");
|
||||
} catch {
|
||||
setDbHealth("error");
|
||||
}
|
||||
|
||||
const hostsResponse = await getSSHHosts();
|
||||
const hosts = Array.isArray(hostsResponse) ? hostsResponse : [];
|
||||
setTotalServers(hosts.length);
|
||||
|
||||
let totalTunnelsCount = 0;
|
||||
for (const host of hosts) {
|
||||
if (host.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections);
|
||||
if (Array.isArray(tunnelConnections)) {
|
||||
totalTunnelsCount += tunnelConnections.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTotalTunnels(totalTunnelsCount);
|
||||
|
||||
const credentialsResponse = await getCredentials();
|
||||
const credentials = Array.isArray(credentialsResponse)
|
||||
? credentialsResponse
|
||||
: [];
|
||||
setTotalCredentials(credentials.length);
|
||||
|
||||
setRecentActivityLoading(true);
|
||||
const activityResponse = await getRecentActivity(35);
|
||||
const activity = Array.isArray(activityResponse)
|
||||
? activityResponse
|
||||
: [];
|
||||
setRecentActivity(activity);
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
|
||||
// Fetch current host statuses once so we can skip offline hosts
|
||||
// before issuing per-host register-viewer / metrics requests.
|
||||
let hostStatuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
hostStatuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
// Best-effort: if the status endpoint is unavailable, fall back
|
||||
// to the previous behavior and still attempt each host.
|
||||
hostStatuses = {};
|
||||
}
|
||||
|
||||
const newViewerSessions = new Map<number, string>();
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
authType?: string;
|
||||
statsConfig?:
|
||||
| string
|
||||
| {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: {
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
} = {
|
||||
metricsEnabled: true,
|
||||
statusCheckEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "none") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host.authType === "opkssh") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip hosts that are known to be offline: no metrics can
|
||||
// possibly exist for them, and hitting /metrics/:id would
|
||||
// just 404. If the status is unknown (e.g. no entry yet
|
||||
// or statusCheckEnabled === false) we still attempt.
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
const knownStatus = hostStatuses?.[host.id]?.status;
|
||||
if (knownStatus === "offline") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const existingSession = viewerSessions.get(host.id);
|
||||
let sessionId = existingSession;
|
||||
let registrationSkipped = false;
|
||||
|
||||
if (!existingSession) {
|
||||
try {
|
||||
const viewerResult = await registerMetricsViewer(host.id);
|
||||
if (viewerResult.skipped) {
|
||||
// Metrics disabled/unsupported on this host; don't
|
||||
// poll and don't surface this as an error.
|
||||
registrationSkipped = true;
|
||||
} else if (
|
||||
viewerResult.success &&
|
||||
viewerResult.viewerSessionId
|
||||
) {
|
||||
sessionId = viewerResult.viewerSessionId;
|
||||
newViewerSessions.set(host.id, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to register viewer for host ${host.id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newViewerSessions.set(host.id, existingSession);
|
||||
}
|
||||
|
||||
if (registrationSkipped) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
if (!metrics) {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
setViewerSessions(newViewerSessions);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(
|
||||
server,
|
||||
): server is {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
} => server !== null && server.cpu !== null && server.ram !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
setRecentActivityLoading(false);
|
||||
setServerStatsLoading(false);
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn || viewerSessions.size === 0) return;
|
||||
|
||||
const heartbeatInterval = setInterval(async () => {
|
||||
for (const [, sessionId] of viewerSessions) {
|
||||
try {
|
||||
await sendMetricsHeartbeat(sessionId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send heartbeat:", error);
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(heartbeatInterval);
|
||||
}, [loggedIn, viewerSessions]);
|
||||
|
||||
const handleResetActivity = async () => {
|
||||
try {
|
||||
await resetRecentActivity();
|
||||
setRecentActivity([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset activity:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "server_stats") {
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "tunnel") {
|
||||
addTab({
|
||||
type: "tunnel",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "docker") {
|
||||
addTab({
|
||||
type: "docker",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "telnet") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "telnet",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "telnet",
|
||||
type: "telnet",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get telnet token:", error);
|
||||
});
|
||||
} else if (item.type === "vnc") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "vnc",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "vnc",
|
||||
type: "vnc",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get vnc token:", error);
|
||||
});
|
||||
} else if (item.type === "rdp") {
|
||||
getGuacamoleTokenFromHost(host.id)
|
||||
.then((result) => {
|
||||
addTab({
|
||||
type: "rdp",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol: "rdp",
|
||||
type: "rdp",
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to get rdp token:", error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleServerStatClick = (serverId: number, serverName: string) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === serverId);
|
||||
if (!host) return;
|
||||
|
||||
addTab({
|
||||
type: "server_stats",
|
||||
title: serverName,
|
||||
hostConfig: host,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
|
||||
}, 100);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "hosts",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-host"));
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
|
||||
}, 100);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "credentials",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent("host-manager:add-credential"));
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdminSettings = () => {
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "admin", title: "Admin Settings" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = () => {
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "user_profile", title: "User Profile" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden flex min-w-0"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: rightMarginPx,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
||||
<SimpleLoader
|
||||
visible={initialLoading}
|
||||
message={t("dashboard.loading")}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="text-2xl text-foreground font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
Press <Kbd>L Shift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Termix",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Support/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold shrink-0 !bg-canvas"
|
||||
variant="outline"
|
||||
onClick={() => setSettingsDialogOpen(true)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-row flex-1 my-5 mx-5 gap-0 min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
{!preferencesLoading &&
|
||||
layout &&
|
||||
(() => {
|
||||
const enabledCards = layout.cards
|
||||
.filter((card) => card.enabled)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const mainCards = enabledCards.filter(
|
||||
(c) => (c.panel ?? "main") === "main",
|
||||
);
|
||||
const sideCards = enabledCards.filter(
|
||||
(c) => c.panel === "side",
|
||||
);
|
||||
|
||||
const renderCard = (card: (typeof enabledCards)[0]) => {
|
||||
if (card.id === "server_overview") {
|
||||
return (
|
||||
<ServerOverviewCard
|
||||
key={card.id}
|
||||
loggedIn={loggedIn}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
uptime={uptime}
|
||||
dbHealth={dbHealth}
|
||||
totalServers={totalServers}
|
||||
totalTunnels={totalTunnels}
|
||||
totalCredentials={totalCredentials}
|
||||
updateCheckDisabled={updateCheckDisabled}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "recent_activity") {
|
||||
return (
|
||||
<RecentActivityCard
|
||||
key={card.id}
|
||||
activities={recentActivity}
|
||||
loading={recentActivityLoading}
|
||||
onReset={handleResetActivity}
|
||||
onActivityClick={handleActivityClick}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "network_graph") {
|
||||
return (
|
||||
<NetworkGraphCard
|
||||
key={card.id}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "quick_actions") {
|
||||
return (
|
||||
<QuickActionsCard
|
||||
key={card.id}
|
||||
isAdmin={isAdmin}
|
||||
onAddHost={handleAddHost}
|
||||
onAddCredential={handleAddCredential}
|
||||
onOpenAdminSettings={handleOpenAdminSettings}
|
||||
onOpenUserProfile={handleOpenUserProfile}
|
||||
/>
|
||||
);
|
||||
} else if (card.id === "server_stats") {
|
||||
return (
|
||||
<ServerStatsCard
|
||||
key={card.id}
|
||||
serverStats={serverStats}
|
||||
loading={serverStatsLoading}
|
||||
onServerClick={handleServerStatClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex flex-col gap-4 overflow-auto min-h-0"
|
||||
style={{ width: `${mainWidthPct}%`, minWidth: 0 }}
|
||||
>
|
||||
{mainCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
{renderCard(card)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 w-2 mx-1.5 flex items-center justify-center cursor-col-resize group"
|
||||
onMouseDown={handleDividerMouseDown}
|
||||
>
|
||||
<div className="w-0.5 h-16 rounded-full bg-border group-hover:bg-primary/50 transition-colors" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-4 overflow-auto min-h-0"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{sideCards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
style={
|
||||
card.height
|
||||
? { height: card.height, flexShrink: 0 }
|
||||
: { flex: 1, minHeight: 280 }
|
||||
}
|
||||
>
|
||||
{renderCard(card)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertManager userId={userId} loggedIn={loggedIn} />
|
||||
|
||||
{layout && (
|
||||
<DashboardSettingsDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
currentLayout={layout}
|
||||
onSave={updateLayout}
|
||||
onReset={resetLayout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FastForward, Server, Key, Settings, User } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
interface QuickActionsCardProps {
|
||||
isAdmin: boolean;
|
||||
onAddHost: () => void;
|
||||
onAddCredential: () => void;
|
||||
onOpenAdminSettings: () => void;
|
||||
onOpenUserProfile: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsCard({
|
||||
isAdmin,
|
||||
onAddHost,
|
||||
onAddCredential,
|
||||
onOpenAdminSettings,
|
||||
onOpenUserProfile,
|
||||
}: QuickActionsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddHost}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Server
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addHost")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onAddCredential}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Key
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.addCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenAdminSettings}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.adminSettings")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
|
||||
onClick={onOpenUserProfile}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-full">
|
||||
<User
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span
|
||||
className="font-semibold text-sm mt-2 text-center block"
|
||||
style={{
|
||||
wordWrap: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
hyphens: "auto",
|
||||
display: "block",
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.userProfile")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Clock,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Server,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { type RecentActivityItem } from "@/main-axios";
|
||||
|
||||
interface RecentActivityCardProps {
|
||||
activities: RecentActivityItem[];
|
||||
loading: boolean;
|
||||
onReset: () => void;
|
||||
onActivityClick: (item: RecentActivityItem) => void;
|
||||
}
|
||||
|
||||
export function RecentActivityCard({
|
||||
activities,
|
||||
loading,
|
||||
onReset,
|
||||
onActivityClick,
|
||||
}: RecentActivityCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-edge h-7 !bg-canvas"
|
||||
onClick={onReset}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
activities
|
||||
.filter((item, index, array) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId && item.type === prevItem.type
|
||||
);
|
||||
})
|
||||
.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge min-w-0 !bg-canvas"
|
||||
onClick={() => onActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : item.type === "file_manager" ? (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
) : item.type === "server_stats" ? (
|
||||
<Server size={20} className="shrink-0" />
|
||||
) : item.type === "tunnel" ? (
|
||||
<ArrowDownUp size={20} className="shrink-0" />
|
||||
) : item.type === "docker" ? (
|
||||
<Container size={20} className="shrink-0" />
|
||||
) : item.type === "telnet" ? (
|
||||
<MessagesSquare size={20} className="shrink-0" />
|
||||
) : item.type === "vnc" ? (
|
||||
<Eye size={20} className="shrink-0" />
|
||||
) : item.type === "rdp" ? (
|
||||
<Monitor size={20} className="shrink-0" />
|
||||
) : (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">{item.hostName}</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Server,
|
||||
History,
|
||||
Clock,
|
||||
Database,
|
||||
Key,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { UpdateLog } from "@/dashboard/panels/UpdateLog";
|
||||
|
||||
interface ServerOverviewCardProps {
|
||||
loggedIn: boolean;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
uptime: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
totalServers: number;
|
||||
totalTunnels: number;
|
||||
totalCredentials: number;
|
||||
updateCheckDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ServerOverviewCard({
|
||||
loggedIn,
|
||||
versionText,
|
||||
versionStatus,
|
||||
uptime,
|
||||
dbHealth,
|
||||
totalServers,
|
||||
totalTunnels,
|
||||
totalCredentials,
|
||||
updateCheckDisabled = false,
|
||||
}: ServerOverviewCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="w-full h-auto border-2 border-edge rounded-md px-3 py-3 !bg-canvas">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
{!updateCheckDisabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : versionStatus === "beta" ? "text-blue-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: versionStatus === "beta"
|
||||
? t("dashboard.beta")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">{uptime}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database size={20} className="shrink-0" />
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalHosts")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<ArrowDownUp size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2 !bg-canvas">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key size={16} className="mr-3 shrink-0" />
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChartLine, Loader2, Server } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
|
||||
interface ServerStat {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
}
|
||||
|
||||
interface ServerStatsCardProps {
|
||||
serverStats: ServerStat[];
|
||||
loading: boolean;
|
||||
onServerClick: (serverId: number, serverName: string) => void;
|
||||
}
|
||||
|
||||
export function ServerStatsCard({
|
||||
serverStats,
|
||||
loading,
|
||||
onServerClick,
|
||||
}: ServerStatsCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-2 border-edge rounded-md flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20 !bg-elevated">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${loading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : serverStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-edge h-auto p-3 min-w-0 !bg-canvas"
|
||||
onClick={() => onServerClick(server.id, server.name)}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">{server.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboard.ram")}:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Label } from "@/components/label";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DashboardLayout } from "@/main-axios";
|
||||
|
||||
interface DashboardSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentLayout: DashboardLayout;
|
||||
onSave: (layout: DashboardLayout) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DashboardSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentLayout,
|
||||
onSave,
|
||||
onReset,
|
||||
}: DashboardSettingsDialogProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [layout, setLayout] = useState<DashboardLayout>(currentLayout);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(currentLayout);
|
||||
}, [currentLayout, open]);
|
||||
|
||||
const handleCardToggle = (cardId: string, enabled: boolean) => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
cards: prev.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, enabled } : card,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCardPanel = (cardId: string, panel: "main" | "side") => {
|
||||
setLayout((prev) => ({
|
||||
...prev,
|
||||
cards: prev.cards.map((card) =>
|
||||
card.id === cardId ? { ...card, panel } : card,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(layout);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onReset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const cardLabels: Record<string, string> = {
|
||||
server_overview: t("dashboard.serverOverviewCard"),
|
||||
recent_activity: t("dashboard.recentActivityCard"),
|
||||
network_graph: t("dashboard.networkGraphCard"),
|
||||
quick_actions: t("dashboard.quickActionsCard"),
|
||||
server_stats: t("dashboard.serverStatsCard"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dashboard.dashboardSettings")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("dashboard.customizeLayout")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold">
|
||||
{t("dashboard.enableDisableCards")}
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
{layout.cards?.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="flex items-center space-x-3 border-2 border-edge rounded-md p-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={card.id}
|
||||
checked={card.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCardToggle(card.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={card.id}
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
{cardLabels[card.id] || card.id}
|
||||
</Label>
|
||||
<Select
|
||||
value={card.panel ?? "main"}
|
||||
onValueChange={(v) =>
|
||||
handleCardPanel(card.id, v as "main" | "side")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-24 h-7 text-xs border-edge">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main">
|
||||
{t("dashboard.panelMain")}
|
||||
</SelectItem>
|
||||
<SelectItem value="side">
|
||||
{t("dashboard.panelSide")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("dashboard.resetLayout")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-2 border-edge"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getDashboardPreferences,
|
||||
saveDashboardPreferences,
|
||||
type DashboardLayout,
|
||||
} from "@/main-axios";
|
||||
|
||||
const DEFAULT_LAYOUT: DashboardLayout = {
|
||||
cards: [
|
||||
{ id: "server_overview", enabled: true, order: 1, panel: "main" },
|
||||
{ id: "quick_actions", enabled: true, order: 2, panel: "main" },
|
||||
{ id: "server_stats", enabled: true, order: 3, panel: "main" },
|
||||
{ id: "network_graph", enabled: false, order: 4, panel: "main" },
|
||||
{ id: "recent_activity", enabled: true, order: 1, panel: "side" },
|
||||
],
|
||||
mainWidthPct: 68,
|
||||
};
|
||||
|
||||
export function useDashboardPreferences(enabled: boolean = true) {
|
||||
const [layout, setLayout] = useState<DashboardLayout | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saveTimeout, setSaveTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
try {
|
||||
const preferences = await getDashboardPreferences();
|
||||
if (preferences?.cards && Array.isArray(preferences.cards)) {
|
||||
// Migrate old layouts that don't have panel assignments
|
||||
const needsMigration = preferences.cards.some((c) => !c.panel);
|
||||
if (needsMigration) {
|
||||
const defaultCardMap = new Map(
|
||||
DEFAULT_LAYOUT.cards.map((c) => [c.id, c]),
|
||||
);
|
||||
const migrated: DashboardLayout = {
|
||||
...preferences,
|
||||
mainWidthPct:
|
||||
preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
|
||||
cards: preferences.cards.map((c) => ({
|
||||
...c,
|
||||
panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
|
||||
})),
|
||||
};
|
||||
setLayout(migrated);
|
||||
} else {
|
||||
setLayout(preferences);
|
||||
}
|
||||
} else {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} catch {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPreferences();
|
||||
}, [enabled]);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(newLayout: DashboardLayout) => {
|
||||
setLayout(newLayout);
|
||||
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
await saveDashboardPreferences(newLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to save dashboard preferences:", error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setSaveTimeout(timeout);
|
||||
},
|
||||
[saveTimeout],
|
||||
);
|
||||
|
||||
const resetLayout = useCallback(async () => {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
try {
|
||||
await saveDashboardPreferences(DEFAULT_LAYOUT);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset dashboard preferences:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
loading,
|
||||
updateLayout,
|
||||
resetLayout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Sheet, SheetContent } from "@/components/sheet.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface UpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: "up_to_date" | "requires_update" | "beta";
|
||||
version: string;
|
||||
localVersion?: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
export function UpdateLog({ loggedIn }: UpdateLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && isOpen) {
|
||||
setLoading(true);
|
||||
Promise.all([getReleasesRSS(100), getVersionInfo()])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("common.failedToFetchUpdateInfo"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn, isOpen]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split("\n")[0];
|
||||
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 border-edge text-muted-foreground"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{t("common.updatesAndReleases")}
|
||||
</Button>
|
||||
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[500px] bg-canvas border-l-2 border-edge text-foreground sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-edge">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t("common.updatesAndReleases")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 thin-scrollbar">
|
||||
{versionInfo && versionInfo.status === "requires_update" && (
|
||||
<Alert className="bg-elevated border-edge text-foreground mb-3">
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("common.updateAvailable")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-foreground-secondary">
|
||||
{t("common.newVersionAvailable", {
|
||||
version: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{versionInfo && versionInfo.status === "beta" && (
|
||||
<Alert className="bg-elevated border-edge text-foreground mb-3">
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("versionCheck.betaVersion")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-foreground-secondary">
|
||||
{t("versionCheck.betaVersionDesc", {
|
||||
current: versionInfo.localVersion,
|
||||
latest: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
|
||||
>
|
||||
<AlertTitle className="text-red-300">
|
||||
{t("common.error")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-300">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-edge rounded-lg p-3 hover:bg-elevated transition-colors cursor-pointer bg-elevated/50"
|
||||
onClick={() => window.open(release.link, "_blank")}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm leading-tight flex-1 text-foreground">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
{t("common.preRelease")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-foreground-secondary mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<span>
|
||||
{new Date(release.pubDate).toLocaleDateString()}
|
||||
</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
{release.assets.length} asset
|
||||
{release.assets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-elevated border-edge text-foreground-secondary">
|
||||
<AlertTitle className="text-foreground-secondary">
|
||||
{t("common.noReleases")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-muted-foreground">
|
||||
{t("common.noReleasesFound")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import {
|
||||
X,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
onDismiss: (alertId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getAlertIcon = (type?: string) => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case "error":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case "success":
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case "info":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadgeVariant = (priority?: string) => {
|
||||
switch (priority) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadgeVariant = (type?: string) => {
|
||||
switch (type) {
|
||||
case "warning":
|
||||
return "secondary";
|
||||
case "error":
|
||||
return "destructive";
|
||||
case "success":
|
||||
return "default";
|
||||
case "info":
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export function AlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: AlertCardProps): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
onDismiss(alert.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getAlertIcon(alert.type)}
|
||||
<CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{alert.priority && (
|
||||
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
|
||||
{alert.priority.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.type && (
|
||||
<Badge variant={getTypeBadgeVariant(alert.type)}>
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{alert.message}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDismiss}>
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() =>
|
||||
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
{alert.actionText}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { AlertCard } from "./AlertCard.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AlertManager({
|
||||
userId,
|
||||
loggedIn,
|
||||
}: AlertManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && userId) {
|
||||
fetchUserAlerts();
|
||||
}
|
||||
}, [loggedIn, userId]);
|
||||
|
||||
const fetchUserAlerts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getUserAlerts();
|
||||
const userAlerts = response.alerts || [];
|
||||
|
||||
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
|
||||
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
const aPriority =
|
||||
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
|
||||
const bPriority =
|
||||
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority;
|
||||
}
|
||||
|
||||
return (
|
||||
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch {
|
||||
toast.error(t("homepage.failedToLoadAlerts"));
|
||||
setError(t("homepage.failedToLoadAlerts"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismissAlert = async (alertId: string) => {
|
||||
try {
|
||||
await dismissAlert(alertId);
|
||||
|
||||
setAlerts((prev) => {
|
||||
const newAlerts = prev.filter((alert) => alert.id !== alertId);
|
||||
return newAlerts;
|
||||
});
|
||||
|
||||
setCurrentAlertIndex((prevIndex) => {
|
||||
const newAlertsLength = alerts.length - 1;
|
||||
if (newAlertsLength === 0) return 0;
|
||||
if (prevIndex >= newAlertsLength)
|
||||
return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch {
|
||||
setError(t("homepage.failedToDismissAlert"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCurrentAlert = () => {
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
} else {
|
||||
setAlerts([]);
|
||||
setCurrentAlertIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousAlert = () => {
|
||||
if (currentAlertIndex > 0) {
|
||||
setCurrentAlertIndex(currentAlertIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextAlert = () => {
|
||||
if (currentAlertIndex < alerts.length - 1) {
|
||||
setCurrentAlertIndex(currentAlertIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!loggedIn || !userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentAlert = alerts[currentAlertIndex];
|
||||
|
||||
if (!currentAlert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
alerts.forEach((alert) => {
|
||||
const priority = alert.priority || "low";
|
||||
priorityCounts[priority as keyof typeof priorityCounts]++;
|
||||
});
|
||||
const hasMultipleAlerts = alerts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||
<div className="relative w-full max-w-2xl mx-4">
|
||||
<AlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
/>
|
||||
|
||||
{hasMultipleAlerts && (
|
||||
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousAlert}
|
||||
disabled={currentAlertIndex === 0}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextAlert}
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
|
||||
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user