feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
+856
View File
@@ -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
+141
View File
@@ -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,
};
}
+222
View File
@@ -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>
);
}