From a0bb56acf3b7e6ebe8e9dacfc858ad59528a0cd0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 5 Oct 2025 21:05:46 +0000 Subject: [PATCH] Refine Proxmox navigation and Docker onboarding --- frontend-modern/src/App.tsx | 157 +++++++++--------- .../src/components/Backups/Backups.tsx | 5 +- .../src/components/Dashboard/Dashboard.tsx | 5 +- .../src/components/Docker/DockerHosts.tsx | 27 ++- .../components/Proxmox/ProxmoxSectionNav.tsx | 83 +++++++++ .../src/components/Settings/DockerAgents.tsx | 51 +++++- .../src/components/Storage/Storage.tsx | 5 +- .../src/components/shared/CopyButton.tsx | 88 ++++++++++ 8 files changed, 332 insertions(+), 89 deletions(-) create mode 100644 frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx create mode 100644 frontend-modern/src/components/shared/CopyButton.tsx diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 4069bdaea..f47003dc1 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -10,7 +10,7 @@ import { runWithOwner, } from 'solid-js'; import type { JSX } from 'solid-js'; -import { Router, Route, useNavigate, useLocation } from '@solidjs/router'; +import { Router, Route, Navigate, useNavigate, useLocation } from '@solidjs/router'; import { getGlobalWebSocketStore } from './stores/websocket-global'; import { Dashboard } from './components/Dashboard/Dashboard'; import StorageComponent from './components/Storage/Storage'; @@ -564,9 +564,16 @@ function App() { // Use Router with routes return ( - } /> - - + } /> + } /> + } + /> + + + } /> + } /> } /> @@ -592,16 +599,56 @@ function AppLayout(props: { }) { const navigate = useNavigate(); const location = useLocation(); + const [dockerTabPreference, setDockerTabPreference] = createSignal(true); + + const DOCKER_VISIBILITY_EVENT = 'pulse:docker-tab-visibility'; + const DOCKER_PREFERENCE_KEY = 'pulse-show-docker-tab'; + + const readDockerPreference = () => { + if (typeof window === 'undefined') return true; + const stored = window.localStorage.getItem(DOCKER_PREFERENCE_KEY); + return stored !== 'false'; + }; + + onMount(() => { + setDockerTabPreference(readDockerPreference()); + + const refreshPreference = (value?: boolean) => { + if (typeof value === 'boolean') { + setDockerTabPreference(value); + return; + } + setDockerTabPreference(readDockerPreference()); + }; + + const handler = (event: Event) => { + if (event instanceof CustomEvent && typeof event.detail?.value === 'boolean') { + refreshPreference(event.detail.value); + } else { + refreshPreference(); + } + }; + + window.addEventListener(DOCKER_VISIBILITY_EVENT, handler); + return () => window.removeEventListener(DOCKER_VISIBILITY_EVENT, handler); + }); // Determine active tab from current path const getActiveTab = () => { const path = location.pathname; - if (path.startsWith('/storage')) return 'storage'; - if (path.startsWith('/backups')) return 'backups'; + if (path.startsWith('/proxmox')) return 'proxmox'; if (path.startsWith('/docker')) return 'docker'; if (path.startsWith('/alerts')) return 'alerts'; if (path.startsWith('/settings')) return 'settings'; - return 'main'; + return 'proxmox'; + }; + + const shouldShowDockerTab = () => { + const hosts = props.state().dockerHosts || []; + if (hosts.length > 0) { + return true; + } + return dockerTabPreference(); }; return ( @@ -763,12 +810,13 @@ function AppLayout(props: { >
navigate('/')} + onClick={() => navigate('/proxmox/overview')} role="tab" + title="Proxmox overview, storage, and backups" > - Main + Proxmox
-
navigate('/storage')} - role="tab" - > - +
navigate('/docker')} + role="tab" > - - - - - Storage -
-
navigate('/backups')} - role="tab" - title="PVE backups, PBS backups, and VM/CT snapshots" - > - - - - - - Backups -
-
navigate('/docker')} - role="tab" - > - - - - Docker -
+ + + + Docker +
+
{ const { state, connected } = useWebSocket(); return ( -
+
+ + {/* Loading State */} diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index acb2ddd8a..72d6e7eb5 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -15,6 +15,7 @@ import type { GuestMetadata } from '@/api/guestMetadata'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; +import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; interface DashboardProps { vms: VM[]; @@ -514,7 +515,9 @@ export function Dashboard(props: DashboardProps) { }; return ( -
+
+ + {/* Unified Node Selector */} = (props) => { title="No Docker hosts configured" description={ - Deploy the Pulse Docker agent on your Docker hosts to collect container metrics. Review the{' '} + Deploy the Pulse Docker agent on at least one Docker host to light up this tab. As soon as an agent reports in, container metrics appear automatically. + + } + actions={ + <> + + Copy install command + - Docker monitoring guide - {' '} - for setup instructions. - + Read the Docker monitoring guide + + } /> diff --git a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx new file mode 100644 index 000000000..41ad7d728 --- /dev/null +++ b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx @@ -0,0 +1,83 @@ +import type { Component, JSX } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; + +type ProxmoxSection = 'overview' | 'storage' | 'backups'; + +interface ProxmoxSectionNavProps { + current: ProxmoxSection; + class?: string; +} + +const sections: Array<{ + id: ProxmoxSection; + label: string; + path: string; + icon: () => JSX.Element; +}> = [ + { + id: 'overview', + label: 'Overview', + path: '/proxmox/overview', + icon: () => ( + + + + + ), + }, + { + id: 'storage', + label: 'Storage', + path: '/proxmox/storage', + icon: () => ( + + + + + + ), + }, + { + id: 'backups', + label: 'Backups', + path: '/proxmox/backups', + icon: () => ( + + + + + + ), + }, +]; + +export const ProxmoxSectionNav: Component = (props) => { + const navigate = useNavigate(); + + const baseClasses = + 'inline-flex items-center gap-1 px-2 sm:px-3 py-1 rounded-full border text-xs sm:text-sm transition-colors focus:outline-none focus-visible:ring focus-visible:ring-blue-400'; + + return ( +
+ {sections.map((section) => { + const Icon = section.icon; + const isActive = section.id === props.current; + const classes = isActive + ? `${baseClasses} bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-700 shadow-sm` + : `${baseClasses} border-transparent text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700/60`; + + return ( + + ); + })} +
+ ); +}; diff --git a/frontend-modern/src/components/Settings/DockerAgents.tsx b/frontend-modern/src/components/Settings/DockerAgents.tsx index b8d367d86..ee0f7c5b5 100644 --- a/frontend-modern/src/components/Settings/DockerAgents.tsx +++ b/frontend-modern/src/components/Settings/DockerAgents.tsx @@ -1,8 +1,9 @@ -import { Component, createSignal, Show, For } from 'solid-js'; +import { Component, createEffect, createSignal, Show, For } from 'solid-js'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { SectionHeader } from '@/components/shared/SectionHeader'; import { formatRelativeTime, formatAbsoluteTime } from '@/utils/format'; +import { Toggle } from '@/components/shared/Toggle'; export const DockerAgents: Component = () => { const { state } = useWebSocket(); @@ -10,6 +11,33 @@ export const DockerAgents: Component = () => { const dockerHosts = () => state.dockerHosts || []; + const STORAGE_KEY = 'pulse-show-docker-tab'; + const readPreference = () => { + if (typeof window === 'undefined') return true; + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored !== 'false'; + }; + + const [showDockerTab, setShowDockerTab] = createSignal(readPreference()); + + const persistPreference = (value: boolean) => { + setShowDockerTab(value); + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, value ? 'true' : 'false'); + window.dispatchEvent( + new CustomEvent('pulse:docker-tab-visibility', { + detail: { value }, + }), + ); + } + }; + + createEffect(() => { + if (dockerHosts().length > 0 && !showDockerTab()) { + persistPreference(true); + } + }); + const pulseUrl = () => { if (typeof window !== 'undefined') { const protocol = window.location.protocol; @@ -95,6 +123,27 @@ WantedBy=multi-user.target`;
+ + +
+
+ Show Docker tab in navigation +
+ persistPreference((event.currentTarget as HTMLInputElement).checked)} + /> +
+

+ Preference is saved per browser. Hiding the tab won’t stop existing Docker hosts from reporting metrics. +

+
+ {/* Deployment Instructions */} diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 7a2c88288..216c243a2 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -10,6 +10,7 @@ import { DiskList } from './DiskList'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; +import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; const Storage: Component = () => { const { state, connected, activeAlerts, initialDataReceived } = useWebSocket(); @@ -496,7 +497,9 @@ const Storage: Component = () => { }; return ( -
+
+ + {/* Node Selector */} { + text: string; + children: JSX.Element; +} + +export function CopyButton(props: CopyButtonProps) { + const [local, others] = splitProps(props, ['text', 'children', 'class', 'onClick']); + const [copied, setCopied] = createSignal(false); + let resetTimeout: number | undefined; + + const handleClick = async (event: MouseEvent) => { + const handler = local.onClick as + | ((event: MouseEvent) => void) + | { handleEvent?: (event: MouseEvent) => void } + | undefined; + + if (typeof handler === 'function') { + handler(event); + } else if (handler && typeof handler.handleEvent === 'function') { + handler.handleEvent(event); + } + if (event.defaultPrevented) { + return; + } + + try { + await navigator.clipboard.writeText(local.text); + setCopied(true); + window.clearTimeout(resetTimeout); + resetTimeout = window.setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy to clipboard', error); + } + }; + + onCleanup(() => { + if (resetTimeout) { + window.clearTimeout(resetTimeout); + } + }); + + return ( + + ); +} + +export default CopyButton;