From 1edfa4311eac38e4ef496e3c241113e87ea77385 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 5 Feb 2026 15:30:30 +0000 Subject: [PATCH] feat: Unified Resource Model and Navigation Redesign ## Summary Complete implementation of the Unified Resource Model with new navigation. ## Features - v2 resources API with identity matching across sources (Proxmox, Agent, Docker) - Infrastructure page with merged host view - Workloads page for all VMs/LXC/Docker containers - Global search (Cmd/Ctrl+K) with keyboard navigation - Mobile navigation with bottom tabs and drawer - Keyboard shortcuts (g+key navigation, ? for help) - What's New modal for user onboarding - Report Incorrect Merge feature for false positive fixes - Debug tab in resource drawer (enable via localStorage) ## Technical - Async audit logging for improved performance - WebSocket-driven real-time updates for unified resources - Session-based auth achieves <2ms API response times ## Tests - Backend: 78 tests passed - Frontend: 397 tests passed --- README.md | 12 + docs/MIGRATION_UNIFIED_NAV.md | 43 ++ frontend-modern/src/App.tsx | 234 ++++++-- .../src/components/Dashboard/Dashboard.tsx | 19 +- .../Infrastructure/ReportMergeModal.tsx | 170 ++++++ .../Infrastructure/ResourceDetailDrawer.tsx | 542 +++++++++++++----- .../Infrastructure/UnifiedResourceTable.tsx | 32 +- .../src/components/Storage/Storage.tsx | 68 ++- .../src/components/Storage/StorageFilter.tsx | 59 +- .../components/shared/CommandPaletteModal.tsx | 196 +++++++ .../src/components/shared/GlobalSearch.tsx | 468 +++++++++++++++ .../shared/KeyboardShortcutsModal.tsx | 98 ++++ .../src/components/shared/MobileNavBar.tsx | 309 ++++++++++ .../src/components/shared/WhatsNewModal.tsx | 155 +++++ .../src/hooks/useKeyboardShortcuts.ts | 164 ++++++ .../src/hooks/useUnifiedResources.ts | 40 +- frontend-modern/src/hooks/useV2Workloads.ts | 6 +- frontend-modern/src/pages/Infrastructure.tsx | 38 +- frontend-modern/src/utils/localStorage.ts | 3 + frontend-modern/src/utils/workloads.ts | 4 +- internal/api/resources_v2.go | 119 +++- internal/api/route_inventory_test.go | 3 + internal/unifiedresources/registry.go | 25 + internal/unifiedresources/types.go | 7 + pkg/audit/async_logger.go | 149 +++++ pkg/audit/audit.go | 14 + pkg/server/server.go | 9 + 27 files changed, 2781 insertions(+), 205 deletions(-) create mode 100644 docs/MIGRATION_UNIFIED_NAV.md create mode 100644 frontend-modern/src/components/Infrastructure/ReportMergeModal.tsx create mode 100644 frontend-modern/src/components/shared/CommandPaletteModal.tsx create mode 100644 frontend-modern/src/components/shared/GlobalSearch.tsx create mode 100644 frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx create mode 100644 frontend-modern/src/components/shared/MobileNavBar.tsx create mode 100644 frontend-modern/src/components/shared/WhatsNewModal.tsx create mode 100644 frontend-modern/src/hooks/useKeyboardShortcuts.ts create mode 100644 pkg/audit/async_logger.go diff --git a/README.md b/README.md index 3917764af..ea2d61a9b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,18 @@ Designed for homelabs, sysadmins, and MSPs who need a "single pane of glass" wit ![Pulse Dashboard](docs/images/01-dashboard.jpg) +## 🧭 Unified Navigation + +Pulse now groups everything by task instead of data source: +- **Infrastructure** for hosts and nodes +- **Workloads** for VMs and containers +- **Storage** and **Backups** as top-level views +- **Services** for PMG instances (when connected) + +Power-user shortcuts: +- `g i` → Infrastructure, `g w` → Workloads, `?` → shortcuts help +- `/` or `Cmd/Ctrl+K` → global search + ## ✨ Features ### Core Monitoring diff --git a/docs/MIGRATION_UNIFIED_NAV.md b/docs/MIGRATION_UNIFIED_NAV.md new file mode 100644 index 000000000..008bdf97f --- /dev/null +++ b/docs/MIGRATION_UNIFIED_NAV.md @@ -0,0 +1,43 @@ +# Migration Guide: Unified Navigation + +This guide explains what changed in the unified navigation release and how to find the new locations for legacy pages. + +## What Changed +- Navigation is now organized by **task** (Infrastructure, Workloads, Storage, Backups, Services) instead of by platform. +- Legacy pages (Proxmox Overview, Hosts, Docker) redirect to unified views. +- Global search and keyboard shortcuts make navigation faster across all resources. + +## Where Old Pages Moved + +| Legacy Page | New Location | +|------------|--------------| +| Proxmox Overview | `/infrastructure` | +| Hosts | `/infrastructure` | +| Docker | `/workloads` (containers) + `/infrastructure` (hosts) | +| Proxmox Storage | `/storage` | +| Proxmox Backups | `/backups` | +| Proxmox Replication | `/backups` (Replication tab) | +| Proxmox Mail Gateway | `/services` | + +## New Features to Know + +### Global Search +- Press `/` or `Cmd/Ctrl+K` to open search. +- Search by name, node, type, tags, or status. +- Results navigate directly to the relevant view. + +### Keyboard Shortcuts +- `g i` → Infrastructure +- `g w` → Workloads +- `g s` → Storage +- `g b` → Backups +- `g a` → Alerts +- `g t` → Settings +- `?` → Shortcut help + +### Debug Drawer (Optional) +- Enable with localStorage key `pulse_debug_mode` for raw JSON in resource drawer. + +## Tips +- If you used Docker and Hosts pages before, start with **Infrastructure** (hosts) and **Workloads** (containers). +- The new pages support unified filters, tags, and search across all sources. diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 7a6f12114..f788dcd0c 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -35,6 +35,10 @@ import { updateStore } from './stores/updates'; import { UpdateBanner } from './components/UpdateBanner'; import { DemoBanner } from './components/DemoBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; +import { WhatsNewModal } from './components/shared/WhatsNewModal'; +import { KeyboardShortcutsModal } from './components/shared/KeyboardShortcutsModal'; +import { CommandPaletteModal } from './components/shared/CommandPaletteModal'; +import { MobileNavBar } from './components/shared/MobileNavBar'; import { createTooltipSystem } from './components/shared/Tooltip'; import type { State, Alert } from '@/types/api'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; @@ -42,6 +46,10 @@ import { startMetricsSampler } from './stores/metricsSampler'; import { seedFromBackend } from './stores/metricsHistory'; import { getMetricsViewMode } from './stores/metricsViewMode'; import BoxesIcon from 'lucide-solid/icons/boxes'; +import ServerIcon from 'lucide-solid/icons/server'; +import HardDriveIcon from 'lucide-solid/icons/hard-drive'; +import ArchiveIcon from 'lucide-solid/icons/archive'; +import WrenchIcon from 'lucide-solid/icons/wrench'; import MonitorIcon from 'lucide-solid/icons/monitor'; import BellIcon from 'lucide-solid/icons/bell'; import SettingsIcon from 'lucide-solid/icons/settings'; @@ -56,15 +64,17 @@ import type { UpdateStatus } from './api/updates'; import { AIChat } from './components/AI/Chat'; import { aiChatStore } from './stores/aiChat'; import { useResourcesAsLegacy } from './hooks/useResources'; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { updateSystemSettingsFromResponse, markSystemSettingsLoadedWithDefaults } from './stores/systemSettings'; import { initKioskMode, isKioskMode, setKioskMode, subscribeToKioskMode, getKioskModePreference } from './utils/url'; +import { showToast } from '@/utils/toast'; +import { GlobalSearch } from '@/components/shared/GlobalSearch'; const Dashboard = lazy(() => import('./components/Dashboard/Dashboard').then((module) => ({ default: module.Dashboard })), ); const StorageComponent = lazy(() => import('./components/Storage/Storage')); -const Backups = lazy(() => import('./components/Backups/Backups')); const UnifiedBackups = lazy(() => import('./components/Backups/UnifiedBackups')); const Replication = lazy(() => import('./components/Replication/Replication')); const MailGateway = lazy(() => import('./components/PMG/MailGateway')); @@ -824,15 +834,6 @@ function App() { // Pass through the store directly (only when initialized) const enhancedStore = () => wsStore(); - // Dashboard view - uses unified resources via useResourcesAsLegacy hook - const DashboardView = () => { - const { asVMs, asContainers, asNodes } = useResourcesAsLegacy(); - - return ( - - ); - }; - // Workloads view - uses v2 workloads with legacy fallback const WorkloadsView = () => { const { asVMs, asContainers, asNodes } = useResourcesAsLegacy(); @@ -847,13 +848,53 @@ function App() { ); }; + const LegacyRedirect = (props: { to: string; toast?: { type: 'info' | 'success' | 'warning' | 'error'; title: string; message?: string } }) => { + const navigate = useNavigate(); + onMount(() => { + if (props.toast) { + showToast(props.toast.type, props.toast.title, props.toast.message); + } + navigate(props.to, { replace: true }); + }); + return null; + }; + const SettingsRoute = () => ( ); // Root layout component for Router const RootLayout = (props: { children?: JSX.Element }) => { - // Check AI settings on mount and setup keyboard shortcut + const [shortcutsOpen, setShortcutsOpen] = createSignal(false); + const [commandPaletteOpen, setCommandPaletteOpen] = createSignal(false); + + const focusGlobalSearch = () => { + if (typeof document === 'undefined') return false; + const el = document.querySelector('[data-global-search]'); + if (!el) return false; + el.focus(); + el.select?.(); + return true; + }; + + useKeyboardShortcuts({ + enabled: () => !needsAuth(), + isShortcutsOpen: shortcutsOpen, + isCommandPaletteOpen: commandPaletteOpen, + onToggleShortcuts: () => { + setCommandPaletteOpen(false); + setShortcutsOpen((prev) => !prev); + }, + onCloseShortcuts: () => setShortcutsOpen(false), + onToggleCommandPalette: () => { + setShortcutsOpen(false); + setCommandPaletteOpen((prev) => !prev); + }, + onCloseCommandPalette: () => setCommandPaletteOpen(false), + onFocusSearch: focusGlobalSearch, + }); + + // Check AI settings on mount and setup escape handling onMount(() => { // Only check AI settings if already authenticated (not on login screen) // Otherwise, the 401 response triggers a redirect loop @@ -869,18 +910,11 @@ function App() { }) .catch(() => { aiChatStore.setEnabled(false); - }); + }); }); } - // Keyboard shortcut: Cmd/Ctrl+K to toggle AI const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - if (aiChatStore.enabled) { - aiChatStore.toggle(); - } - } // Escape to close if (e.key === 'Escape' && aiChatStore.isOpen) { aiChatStore.close(); @@ -916,6 +950,7 @@ function App() { + {/* Main layout container - flexbox to allow AI panel to push content */} @@ -941,6 +976,14 @@ function App() { {/* AI Panel - slides in from right, pushes content */} aiChatStore.close()} /> + setShortcutsOpen(false)} + /> + setCommandPaletteOpen(false)} + /> {/* AI Assistant Button moved to AppLayout to access kioskMode state */} @@ -959,19 +1002,35 @@ function App() { } /> } /> - + ( + + )} + /> - + } /> - + } /> - + ( + + )} + /> - + } /> } /> @@ -1124,6 +1183,11 @@ function AppLayout(props: { // Determine active tab from current path const getActiveTab = () => { const path = location.pathname; + if (path.startsWith('/infrastructure')) return 'infrastructure'; + if (path.startsWith('/workloads')) return 'workloads'; + if (path.startsWith('/storage')) return 'storage'; + if (path.startsWith('/backups')) return 'backups'; + if (path.startsWith('/services')) return 'services'; if (path.startsWith('/proxmox')) return 'proxmox'; if (path.startsWith('/docker')) return 'docker'; if (path.startsWith('/kubernetes')) return 'kubernetes'; @@ -1137,6 +1201,7 @@ function AppLayout(props: { const hasDockerHosts = createMemo(() => (props.state().dockerHosts?.length ?? 0) > 0); const hasKubernetesClusters = createMemo(() => (props.state().kubernetesClusters?.length ?? 0) > 0); const hasHosts = createMemo(() => (props.state().hosts?.length ?? 0) > 0); + const hasPMGServices = createMemo(() => (props.state().pmg?.length ?? 0) > 0); const hasProxmoxHosts = createMemo( () => (props.state().nodes?.length ?? 0) > 0 || @@ -1168,33 +1233,113 @@ function AppLayout(props: { } }); + type PlatformTab = { + id: string; + label: string; + route: string; + settingsRoute: string; + tooltip: string; + enabled: boolean; + live: boolean; + icon: JSX.Element; + alwaysShow: boolean; + badge?: string; + }; + const platformTabs = createMemo(() => { - const allPlatforms = [ + const allPlatforms: PlatformTab[] = [ + { + id: 'infrastructure' as const, + label: 'Infrastructure', + route: '/infrastructure', + settingsRoute: '/settings', + tooltip: 'All hosts and nodes across platforms', + enabled: true, + live: true, + icon: ( + + ), + alwaysShow: true, + }, + { + id: 'workloads' as const, + label: 'Workloads', + route: '/workloads', + settingsRoute: '/settings', + tooltip: 'VMs, containers, and Kubernetes workloads', + enabled: true, + live: true, + icon: ( + + ), + alwaysShow: true, + }, + { + id: 'storage' as const, + label: 'Storage', + route: '/storage', + settingsRoute: '/settings', + tooltip: 'Storage pools, disks, and Ceph', + enabled: true, + live: true, + icon: ( + + ), + alwaysShow: true, + }, + { + id: 'backups' as const, + label: 'Backups', + route: '/backups', + settingsRoute: '/settings', + tooltip: 'Backup jobs, history, and replication', + enabled: true, + live: true, + icon: ( + + ), + alwaysShow: true, + }, + { + id: 'services' as const, + label: 'Services', + route: '/services', + settingsRoute: '/settings', + tooltip: 'Mail gateway status and service health', + enabled: hasPMGServices(), + live: hasPMGServices(), + icon: ( + + ), + alwaysShow: false, + }, { id: 'proxmox' as const, - label: 'Proxmox', + label: 'Proxmox Overview', route: '/proxmox/overview', settingsRoute: '/settings', - tooltip: 'Monitor Proxmox clusters and nodes', + tooltip: 'Legacy Proxmox dashboard', enabled: hasProxmoxHosts() || !!seenPlatforms()['proxmox'], live: hasProxmoxHosts(), icon: ( ), alwaysShow: true, // Proxmox is the default, always show + badge: 'Legacy', }, { id: 'docker' as const, label: 'Docker', route: '/docker', settingsRoute: '/settings/docker', - tooltip: 'Monitor Docker hosts and containers', + tooltip: 'Legacy Docker hosts and containers', enabled: hasDockerHosts() || !!seenPlatforms()['docker'], live: hasDockerHosts(), icon: ( ), alwaysShow: true, // Docker is commonly used, keep visible + badge: 'Legacy', }, { id: 'kubernetes' as const, @@ -1214,13 +1359,14 @@ function AppLayout(props: { label: 'Hosts', route: '/hosts', settingsRoute: '/settings/host-agents', - tooltip: 'Monitor hosts with the host agent', + tooltip: 'Legacy hosts view', enabled: hasHosts() || !!seenPlatforms()['hosts'], live: hasHosts(), icon: ( ), alwaysShow: true, // Hosts is commonly used, keep visible + badge: 'Legacy', }, ]; @@ -1314,7 +1460,9 @@ function AppLayout(props: { }; return ( -
+
{/* Header - simplified in kiosk mode */}
@@ -1411,11 +1559,16 @@ function AppLayout(props: { />
+ +
+ +
+
{/* Tabs - hidden in kiosk mode */}
@@ -1451,7 +1604,14 @@ function AppLayout(props: { title={title()} > {platform.icon} - + {platform.label.charAt(0)}
); @@ -1538,6 +1698,16 @@ function AppLayout(props: {
+ + + + {/* Footer - hidden in kiosk mode */}