From 0216a2d2fe715dff8acf1d323b4264c6625e2d99 Mon Sep 17 00:00:00 2001 From: Steven Josefs Date: Tue, 30 Dec 2025 18:20:57 +0100 Subject: [PATCH] Fixing PR442: - Fixed: - UI design elemets - UI and button colors - JSON export - recent activity is default again - Removed: - Online/Offline UI labels - left-click menu on hosts - Added: - small pulsing dot inside the hosts to indicate online status like in the left bar --- src/ui/desktop/DesktopApp.tsx | 9 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 2 +- .../network-graph/NetworkGraphView.tsx | 328 +++++++++++++----- src/ui/desktop/navigation/AppView.tsx | 11 +- 4 files changed, 257 insertions(+), 93 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 56bd39ca..09f7b7c1 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -292,8 +292,13 @@ function AppContent() { )} {showNetworkGraph && ( -
- +
+
)} diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 68ec6ce8..a40ad983 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -89,7 +89,7 @@ export function Dashboard({ Array<{ id: number; name: string; cpu: number | null; ram: number | null }> >([]); const [serverStatsLoading, setServerStatsLoading] = useState(true); - const [showNetworkGraph, setShowNetworkGraph] = useState(true); + const [showNetworkGraph, setShowNetworkGraph] = useState(false); const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); diff --git a/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx b/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx index b78746e8..dc76a4cb 100644 --- a/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx +++ b/src/ui/desktop/dashboard/network-graph/NetworkGraphView.tsx @@ -8,12 +8,14 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from ' import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { - Plus, Trash2, Move3D, ZoomIn, ZoomOut, RotateCw, Loader2, AlertCircle, +import { + Plus, Trash2, Move3D, ZoomIn, ZoomOut, RotateCw, Loader2, AlertCircle, Download, Upload, Link2, FolderPlus, Edit, FolderInput, FolderMinus, Settings2, ExternalLink, Terminal } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTabs } from '@/ui/desktop/navigation/tabs/TabContext'; +import { useSidebar } from '@/components/ui/sidebar'; +import { Separator } from '@/components/ui/separator'; // --- Helper for edge routing --- const getEndpoints = (edge: cytoscape.EdgeSingular): { sourceEndpoint: string; targetEndpoint: string } => { @@ -48,9 +50,29 @@ interface ContextMenuState { type: 'node' | 'group' | 'edge' | null; } -const NetworkGraphView: React.FC = () => { +interface NetworkGraphViewProps { + isTopbarOpen?: boolean; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; + isStandalone?: boolean; +} + +const NetworkGraphView: React.FC = ({ + isTopbarOpen = true, + rightSidebarOpen = false, + rightSidebarWidth = 400, + isStandalone = false +}) => { const { t } = useTranslation(); const { addTab } = useTabs(); + + let sidebarState: "expanded" | "collapsed" = "expanded"; + try { + const sidebar = useSidebar(); + sidebarState = sidebar.state; + } catch (error) { + // Not in SidebarProvider context (e.g., during authentication) + } // --- State --- const [elements, setElements] = useState([]); @@ -100,6 +122,105 @@ const NetworkGraphView: React.FC = () => { // Sync refs useEffect(() => { hostMapRef.current = hostMap; }, [hostMap]); + // --- Pulsing Dot Overlay for Online Nodes Only --- + useEffect(() => { + if (!cyRef.current) return; + + const canvas = document.createElement('canvas'); + const container = cyRef.current.container(); + if (!container) return; + + canvas.style.position = 'absolute'; + canvas.style.top = '0'; + canvas.style.left = '0'; + canvas.style.pointerEvents = 'none'; + canvas.style.zIndex = '999'; + container.appendChild(canvas); + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animationFrame: number; + let startTime = Date.now(); + + const animate = () => { + if (!cyRef.current || !ctx) return; + + const canvasWidth = cyRef.current.width(); + const canvasHeight = cyRef.current.height(); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + const elapsed = Date.now() - startTime; + const cycle = (elapsed % 2000) / 2000; + const zoom = cyRef.current.zoom(); + + // Scale pulse radius with zoom + const baseRadius = 4; + const pulseAmount = 3; + const pulseRadius = (baseRadius + Math.sin(cycle * Math.PI * 2) * pulseAmount) * zoom; + const pulseOpacity = 0.6 - Math.sin(cycle * Math.PI * 2) * 0.4; + + // Draw pulsing green dot for ONLINE nodes only + cyRef.current.nodes('[status="online"]').forEach(node => { + if (node.isParent()) return; + + // Use renderedPosition which accounts for zoom and pan + const pos = node.renderedPosition(); + // Node dimensions at current zoom + const nodeWidth = 180 * zoom; + const nodeHeight = 90 * zoom; + + // Position dot at top-right corner (same relative position as SVG) + const dotX = pos.x + (nodeWidth / 2) - (10 * zoom); + const dotY = pos.y - (nodeHeight / 2) + (10 * zoom); + + // Draw pulsing ring + ctx.beginPath(); + ctx.arc(dotX, dotY, pulseRadius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(16, 185, 129, ${pulseOpacity})`; + ctx.fill(); + + // Draw solid center dot + ctx.beginPath(); + ctx.arc(dotX, dotY, baseRadius * zoom, 0, Math.PI * 2); + ctx.fillStyle = '#10b981'; + ctx.fill(); + }); + + // Draw static red dot for OFFLINE nodes (no pulsing) + cyRef.current.nodes('[status="offline"]').forEach(node => { + if (node.isParent()) return; + + const pos = node.renderedPosition(); + const nodeWidth = 180 * zoom; + const nodeHeight = 90 * zoom; + + const dotX = pos.x + (nodeWidth / 2) - (10 * zoom); + const dotY = pos.y - (nodeHeight / 2) + (10 * zoom); + + // Draw solid red dot (no animation) + ctx.beginPath(); + ctx.arc(dotX, dotY, 4 * zoom, 0, Math.PI * 2); + ctx.fillStyle = '#ef4444'; + ctx.fill(); + }); + + animationFrame = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + if (animationFrame) cancelAnimationFrame(animationFrame); + if (container && canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [elements]); + // --- Initialization --- useEffect(() => { @@ -270,14 +391,16 @@ const NetworkGraphView: React.FC = () => { const name = host.label || ''; const ip = host.ip || ''; const tags = host.tags || []; + const isOnline = host.status === 'online'; + const isOffline = host.status === 'offline'; const statusColor = - host.status === 'online' ? '#22c55e' : - (host.status === 'offline' ? '#ef4444' : '#64748b'); + isOnline ? '#10b981' : + isOffline ? '#ef4444' : '#64748b'; const tagsHtml = tags.map(t => ` ${t}`).join(''); + // Changed rx from 8 to 6 to match Dashboard's rounded-md (0.375rem = 6px) + // Status indicator dot drawn on canvas overlay (see useEffect for pulsing animation) const svg = ` - - + + - +
+ // Calculate margins for standalone mode (like Admin Settings and User Profile) + const topMarginPx = isStandalone ? (isTopbarOpen ? 74 : 26) : 0; + const leftMarginPx = isStandalone ? (sidebarState === "collapsed" ? 26 : 8) : 0; + const bottomMarginPx = isStandalone ? 8 : 0; + const wrapperStyle: React.CSSProperties = isStandalone ? { + marginLeft: leftMarginPx, + marginRight: rightSidebarOpen + ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` + : 17, + marginTop: topMarginPx, + marginBottom: bottomMarginPx, + height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, + transition: + "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear", + } : {}; + + const content = ( +
{error && (
@@ -620,50 +753,49 @@ const NetworkGraphView: React.FC = () => { )} {/* --- Toolbar --- */} -
- +
-
- - - -
-
- - - -
-
+
- { @@ -680,68 +812,63 @@ const NetworkGraphView: React.FC = () => { }} className="hidden" />
-
+
- -
- Online - Offline -
{/* --- Graph Area --- */} -
+
{loading && (
)} - + {/* Context Menu - Fixed Position with High Z-Index */} {contextMenu.visible && ( -
{contextMenu.type === 'node' && ( <> - - - {cyRef.current?.$id(contextMenu.targetId).parent().length ? ( - ) : null} )} - + {contextMenu.type === 'group' && ( <> - - )} -
-
@@ -761,14 +888,14 @@ const NetworkGraphView: React.FC = () => { {/* --- Dialogs --- */} - + Add Host
- - + + No Group {availableGroups.map(g => ( {g.label} @@ -789,7 +916,7 @@ const NetworkGraphView: React.FC = () => {
- +
@@ -798,30 +925,30 @@ const NetworkGraphView: React.FC = () => { { if(!open) { setShowAddGroupDialog(false); setShowEditGroupDialog(false); } }}> - + {showEditGroupDialog ? 'Edit Group' : 'Create Group'}
- setNewGroupName(e.target.value)} placeholder="e.g. Cluster A" style={{backgroundColor: 'var(--color-dark-bg-input)', borderColor: 'var(--color-dark-border)'}} /> + setNewGroupName(e.target.value)} placeholder="e.g. Cluster A" className="bg-dark-bg-input border-dark-border" />
-
- + setNewGroupColor(e.target.value)} - className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent" + className="w-8 h-8 p-0 border-0 rounded cursor-pointer bg-transparent" /> {newGroupColor}
- + @@ -830,14 +957,14 @@ const NetworkGraphView: React.FC = () => {
- + Move to Group
- - + + {availableNodesForConnection.map(el => ( {el.label} ))} @@ -871,8 +998,8 @@ const NetworkGraphView: React.FC = () => {