From ef35120afb4ea519c152da666b50c3188b64c91c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Dec 2025 22:54:27 +0000 Subject: [PATCH] checkpoint: remove unified resources UI, keep backend model - Removed /resources page and associated frontend components - Removed ResourcesOverview.tsx, UnifiedResourceRow.tsx, columns.ts - Removed frontend types/resource.ts - Updated unified-resource-architecture.md to mark Phase 4 as ABANDONED - Removed unified-view-migration-plan.md - Backend unified resource model remains for AI context This is a checkpoint before attempting full frontend migration to unified model. --- .gemini/docs/unified-resource-architecture.md | 49 +- frontend-modern/src/App.tsx | 10 +- .../Resources/ResourcesOverview.tsx | 462 ------------------ frontend-modern/src/types/resource.ts | 161 ------ frontend-modern/src/utils/apiClient.ts | 6 +- frontend-modern/src/utils/localStorage.ts | 3 + internal/monitoring/metrics.db | Bin 0 -> 28672 bytes internal/resources/store.go | 18 +- 8 files changed, 49 insertions(+), 660 deletions(-) delete mode 100644 frontend-modern/src/components/Resources/ResourcesOverview.tsx delete mode 100644 frontend-modern/src/types/resource.ts create mode 100644 internal/monitoring/metrics.db diff --git a/.gemini/docs/unified-resource-architecture.md b/.gemini/docs/unified-resource-architecture.md index fca645e..3c907a1 100644 --- a/.gemini/docs/unified-resource-architecture.md +++ b/.gemini/docs/unified-resource-architecture.md @@ -401,36 +401,37 @@ func (m *Monitor) pollPVENode(...) (models.Node, string, error) { - Reduced API load (skip redundant polling) - More AI capabilities (command execution via agents) -### Phase 4: Optional Unified View (Future) +### Phase 4: Unified Monitoring Page (ABANDONED) -**Goal:** Add a consolidated "All Resources" view for power users. +**Goal:** Replace separate platform pages with ONE unified monitoring experience. -**Status:** ✅ Basic implementation complete +**Status:** ❌ **ABANDONED** - Prototyped but reverted in favor of existing pages -**Completed Tasks:** -1. ✅ Created `src/types/resource.ts` - TypeScript types matching Go backend -2. ✅ Created `src/components/Resources/ResourcesOverview.tsx` - Unified view component -3. ✅ Route added: `/resources` -4. ✅ Filtering by type, platform, status -5. ✅ Grouping by type, platform, or parent +**Decision (2025-12-07):** +After building and testing the unified resources view, we determined that the **existing platform-specific pages are superior** in terms of: +- User experience and layout +- Feature richness +- Familiarity for existing users -**Remaining Tasks:** -- [ ] Add navigation link to UI (currently hidden power-user route) -- [ ] Add resource detail drawer/modal -- [ ] Add AI context integration (click to add to AI chat) -- [ ] Add tag-based filtering -- [ ] Add export functionality +The backend unified resource model (Phases 1-3) remains valuable for: +- AI context enhancement (cross-platform intelligence) +- Deduplication logic (avoiding duplicate alerts) +- Future API consumers -**Features:** -- Fetches from `/api/resources` endpoint -- Auto-refreshes every 10 seconds -- Filtering by search, type, platform, status -- Grouping by type/platform/parent -- Status indicators with alert badges -- CPU/Memory/Disk progress bars +**What was removed:** +- `src/components/Resources/ResourcesOverview.tsx` +- `src/components/Resources/UnifiedResourceRow.tsx` +- `src/components/Resources/columns.ts` +- `src/types/resource.ts` +- `/resources` route -**Access:** -Navigate to `/resources` directly (not yet in main navigation) +**What remains:** +- Backend `/api/resources` endpoint (for AI and API consumers) +- Backend unified resource store (`internal/resources/`) +- Platform-specific frontend pages (Dashboard, Docker, Hosts) + +**Lesson Learned:** +Sometimes the best architecture is to keep what works. The existing pages have years of refinement and user-driven features. A "unified" view lost much of that polish. ### Phase 5: New Platform Support (Ongoing) diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index e8e0a38..bbdc184 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -67,11 +67,7 @@ const HostsOverview = lazy(() => default: module.HostsOverview, })), ); -const ResourcesOverview = lazy(() => - import('./components/Resources/ResourcesOverview').then((module) => ({ - default: module.ResourcesOverview, - })), -); + // Enhanced store type with proper typing type EnhancedStore = ReturnType; @@ -242,7 +238,7 @@ function App() { () => import('./components/Replication/Replication'), () => import('./components/PMG/MailGateway'), () => import('./components/Hosts/HostsOverview'), - () => import('./components/Resources/ResourcesOverview'), + () => import('./pages/Alerts'), () => import('./components/Settings/Settings'), () => import('./components/Docker/DockerHosts'), @@ -878,7 +874,7 @@ function App() { } /> - + } /> diff --git a/frontend-modern/src/components/Resources/ResourcesOverview.tsx b/frontend-modern/src/components/Resources/ResourcesOverview.tsx deleted file mode 100644 index f2c79e2..0000000 --- a/frontend-modern/src/components/Resources/ResourcesOverview.tsx +++ /dev/null @@ -1,462 +0,0 @@ -import type { Component } from 'solid-js'; -import { For, Show, createMemo, createSignal, onMount, onCleanup, createResource } from 'solid-js'; -import type { Resource, ResourceType, PlatformType, ResourceStatus, ResourcesResponse } from '@/types/resource'; -import { - RESOURCE_TYPE_LABELS, - PLATFORM_LABELS, - STATUS_LABELS, - getStatusVariant, - isInfrastructureType, - isWorkloadType -} from '@/types/resource'; -import { formatBytes, formatUptime, formatRelativeTime } from '@/utils/format'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { apiFetchJSON } from '@/utils/apiClient'; - -// Fetch resources from API -async function fetchResources(): Promise { - try { - const response = await apiFetchJSON('/api/resources'); - return response; - } catch (error) { - console.error('Failed to fetch resources:', error); - return { resources: [], count: 0, stats: { totalResources: 0, byType: {}, byPlatform: {}, byStatus: {}, withAlerts: 0, lastUpdated: '' } }; - } -} - -// Filter panel component -interface ResourceFilterProps { - search: () => string; - setSearch: (value: string) => void; - typeFilter: () => ResourceType | 'all'; - setTypeFilter: (value: ResourceType | 'all') => void; - platformFilter: () => PlatformType | 'all'; - setPlatformFilter: (value: PlatformType | 'all') => void; - statusFilter: () => ResourceStatus | 'all'; - setStatusFilter: (value: ResourceStatus | 'all') => void; - groupBy: () => 'none' | 'type' | 'platform' | 'parent'; - setGroupBy: (value: 'none' | 'type' | 'platform' | 'parent') => void; - stats: () => ResourcesResponse['stats'] | undefined; -} - -const ResourceFilter: Component = (props) => { - const selectClass = 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500'; - - return ( - -
- {/* Search */} -
- props.setSearch(e.currentTarget.value)} - class="w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- - {/* Type filter */} - - - {/* Platform filter */} - - - {/* Status filter */} - - - {/* Group by */} - -
- - {/* Stats bar */} - -
- {props.stats()!.totalResources} resources - 0}> - - {props.stats()!.withAlerts} with alerts - - - - - {([status, count]) => ( - - - {count} {status} - - )} - -
-
-
- ); -}; - -// Resource row component -interface ResourceRowProps { - resource: Resource; - showParent?: boolean; - onSelect?: (resource: Resource) => void; -} - -const ResourceRow: Component = (props) => { - const { resource } = props; - - const cpuPercent = resource.cpu?.current ?? 0; - const memPercent = resource.memory?.current ?? 0; - const diskPercent = resource.disk?.current ?? 0; - - const getProgressColor = (value: number) => { - if (value >= 90) return 'bg-red-500'; - if (value >= 75) return 'bg-yellow-500'; - return 'bg-blue-500'; - }; - - return ( - props.onSelect?.(resource)} - > - {/* Name & Status */} - -
- -
-

- {resource.displayName || resource.name} -

- -

{resource.name}

-
-
- 0}> - - {resource.alerts.length} alert{resource.alerts.length > 1 ? 's' : ''} - - -
- - - {/* Type */} - - - {RESOURCE_TYPE_LABELS[resource.type]} - - - - {/* Platform */} - - - {PLATFORM_LABELS[resource.platform]} - - - - {/* Source */} - - - {resource.sourceType} - - - - {/* CPU */} - - —}> -
-
-
-
- - {cpuPercent.toFixed(0)}% - -
- - - - {/* Memory */} - - —}> -
-
-
-
- - {memPercent.toFixed(0)}% - -
- - - - {/* Disk */} - - —}> -
-
-
-
- - {diskPercent.toFixed(0)}% - -
- - - - {/* Uptime */} - - - {resource.uptime ? formatUptime(resource.uptime) : '—'} - - - - {/* Last Seen */} - - - {resource.lastSeen ? formatRelativeTime(resource.lastSeen) : '—'} - - - - ); -}; - -// Main Resources Overview component -export const ResourcesOverview: Component = () => { - const [search, setSearch] = createSignal(''); - const [typeFilter, setTypeFilter] = createSignal('all'); - const [platformFilter, setPlatformFilter] = createSignal('all'); - const [statusFilter, setStatusFilter] = createSignal('all'); - const [groupBy, setGroupBy] = createSignal<'none' | 'type' | 'platform' | 'parent'>('none'); - - // Fetch resources with auto-refresh - const [data, { refetch }] = createResource(fetchResources); - - // Auto-refresh every 10 seconds - let refreshInterval: number; - onMount(() => { - refreshInterval = window.setInterval(() => refetch(), 10000); - }); - onCleanup(() => { - if (refreshInterval) clearInterval(refreshInterval); - }); - - // Filter resources - const filteredResources = createMemo(() => { - if (!data()) return []; - let resources = data()!.resources; - - // Apply search - if (search()) { - const term = search().toLowerCase(); - resources = resources.filter(r => - r.name.toLowerCase().includes(term) || - r.displayName.toLowerCase().includes(term) || - r.identity?.hostname?.toLowerCase().includes(term) || - r.identity?.primaryIp?.includes(term) - ); - } - - // Apply type filter - if (typeFilter() !== 'all') { - resources = resources.filter(r => r.type === typeFilter()); - } - - // Apply platform filter - if (platformFilter() !== 'all') { - resources = resources.filter(r => r.platform === platformFilter()); - } - - // Apply status filter - if (statusFilter() !== 'all') { - resources = resources.filter(r => r.status === statusFilter()); - } - - return resources; - }); - - // Group resources - const groupedResources = createMemo(() => { - const resources = filteredResources(); - const mode = groupBy(); - - if (mode === 'none') { - return [{ key: 'all', label: 'All Resources', resources }]; - } - - const groups: Record = {}; - - for (const r of resources) { - let key: string; - switch (mode) { - case 'type': - key = r.type; - break; - case 'platform': - key = r.platform; - break; - case 'parent': - key = r.parentId || 'no-parent'; - break; - default: - key = 'all'; - } - if (!groups[key]) groups[key] = []; - groups[key].push(r); - } - - return Object.entries(groups).map(([key, resources]) => ({ - key, - label: mode === 'type' ? RESOURCE_TYPE_LABELS[key as ResourceType] || key : - mode === 'platform' ? PLATFORM_LABELS[key as PlatformType] || key : - key === 'no-parent' ? 'Standalone Resources' : key, - resources - })).sort((a, b) => a.label.localeCompare(b.label)); - }); - - const thClass = "px-2 py-2 text-left text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wider"; - - return ( -
-
-

All Resources

- -
- - data()?.stats} - /> - - - - - - - - } - title="Loading resources..." - description="Fetching unified resource data." - /> - - - - - - - - - - 0}> - - {(group) => ( - - -
-

- {group.label} - ({group.resources.length}) -

-
-
-
- - - - - - - - - - - - - - - - - {(resource) => } - - -
NameTypePlatformSourceCPUMemoryDiskUptimeLast Seen
-
-
- )} -
-
-
- ); -}; - -export default ResourcesOverview; diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts deleted file mode 100644 index 7643014..0000000 --- a/frontend-modern/src/types/resource.ts +++ /dev/null @@ -1,161 +0,0 @@ -// Unified Resource type definitions - matches backend internal/resources/resource.go - -import type { StatusIndicatorVariant } from '@/utils/status'; - -export type ResourceType = - | 'node' - | 'vm' - | 'container' - | 'host' - | 'docker_host' - | 'docker_container' - | 'pbs_instance' - | 'storage' - | 'datastore'; - -export type PlatformType = - | 'proxmox_pve' - | 'proxmox_pbs' - | 'proxmox_pmg' - | 'docker' - | 'host_agent' - | 'multi' - | 'unknown'; - -export type ResourceStatus = - | 'online' - | 'offline' - | 'running' - | 'stopped' - | 'paused' - | 'degraded' - | 'unknown'; - -export type SourceType = 'api' | 'agent' | 'hybrid'; - -export interface MetricValue { - current: number; // Current value as percentage - total?: number; // Total capacity (bytes for memory/disk) - used?: number; // Used amount (bytes for memory/disk) - free?: number; // Free amount (bytes for memory/disk) -} - -export interface NetworkMetric { - inBytes: number; - outBytes: number; - inRate: number; - outRate: number; -} - -export interface ResourceAlert { - id: string; - severity: 'warning' | 'critical'; - message: string; - timestamp: string; -} - -export interface ResourceIdentity { - hostname?: string; - machineId?: string; - primaryIp?: string; -} - -export interface Resource { - id: string; - name: string; - displayName: string; - type: ResourceType; - platform: PlatformType; - sourceType: SourceType; - sourceId: string; - parentId?: string; - status: ResourceStatus; - cpu?: MetricValue; - memory?: MetricValue; - disk?: MetricValue; - network?: NetworkMetric; - uptime?: number; - temperature?: number; - tags?: string[]; - metadata?: Record; - alerts?: ResourceAlert[]; - identity?: ResourceIdentity; - lastSeen?: string; - createdAt?: string; - updatedAt?: string; - // Platform-specific data - platformData?: Record; -} - -export interface StoreStats { - totalResources: number; - byType: Record; - byPlatform: Record; - byStatus: Record; - withAlerts: number; - lastUpdated: string; -} - -export interface ResourcesResponse { - resources: Resource[]; - count: number; - stats: StoreStats; -} - -// Type guards -export function isInfrastructureType(type: ResourceType): boolean { - return ['node', 'host', 'docker_host', 'pbs_instance'].includes(type); -} - -export function isWorkloadType(type: ResourceType): boolean { - return ['vm', 'container', 'docker_container'].includes(type); -} - -// Display helpers -export const RESOURCE_TYPE_LABELS: Record = { - node: 'Proxmox Node', - vm: 'Virtual Machine', - container: 'LXC Container', - host: 'Host Machine', - docker_host: 'Docker Host', - docker_container: 'Docker Container', - pbs_instance: 'PBS Instance', - storage: 'Storage', - datastore: 'Datastore', -}; - -export const PLATFORM_LABELS: Record = { - proxmox_pve: 'Proxmox VE', - proxmox_pbs: 'Proxmox PBS', - proxmox_pmg: 'Proxmox PMG', - docker: 'Docker', - host_agent: 'Host Agent', - multi: 'Multiple', - unknown: 'Unknown', -}; - -export const STATUS_LABELS: Record = { - online: 'Online', - offline: 'Offline', - running: 'Running', - stopped: 'Stopped', - paused: 'Paused', - degraded: 'Degraded', - unknown: 'Unknown', -}; - -export function getStatusVariant(status: ResourceStatus): StatusIndicatorVariant { - switch (status) { - case 'online': - case 'running': - return 'success'; - case 'paused': - case 'degraded': - return 'warning'; - case 'offline': - case 'stopped': - return 'danger'; - default: - return 'muted'; - } -} diff --git a/frontend-modern/src/utils/apiClient.ts b/frontend-modern/src/utils/apiClient.ts index 089e0e7..ed4684f 100644 --- a/frontend-modern/src/utils/apiClient.ts +++ b/frontend-modern/src/utils/apiClient.ts @@ -249,8 +249,10 @@ class ApiClient { const response = await fetch(url, finalOptions); // If we get a 401 on an API call (not during initial auth check), redirect to login - // Skip redirect for specific auth-check endpoints to avoid loops - if (response.status === 401 && !url.includes('/api/security/status') && !url.includes('/api/state') && !url.includes('/api/settings/ai')) { + // Skip redirect for specific auth-check endpoints and background data fetching to avoid loops + const skipRedirectUrls = ['/api/security/status', '/api/state', '/api/settings/ai', '/api/charts', '/api/resources']; + const shouldSkipRedirect = skipRedirectUrls.some(path => url.includes(path)); + if (response.status === 401 && !shouldSkipRedirect) { logger.warn('Authentication expired - redirecting to login'); // Clear auth and redirect to login if (typeof window !== 'undefined') { diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 1b273f6..05acfdf 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -114,4 +114,7 @@ export const STORAGE_KEYS = { DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns', HOSTS_HIDDEN_COLUMNS: 'hostsHiddenColumns', DOCKER_HIDDEN_COLUMNS: 'dockerHiddenColumns', + + // Resources search + RESOURCES_SEARCH_HISTORY: 'resourcesSearchHistory', } as const; diff --git a/internal/monitoring/metrics.db b/internal/monitoring/metrics.db new file mode 100644 index 0000000000000000000000000000000000000000..4d1f0c31f1e99c6bddd20d88612b5cc25bd1a4ac GIT binary patch literal 28672 zcmeI&zi!$<9KdlqAyA??%}|Mf8&k`}6zUZ?Vj$k99wg;_GmH;|1c_I514pI2FP$jFdVv z>RcYWtP8qL%zp|{pk>0yE_W$gUtiM*UcW)g((}xfO2q1s}0tg_000Iag z@K_+&$>a(Jb6yU-FMW9iMbTO@m?B*0Zlx z(K%Xg>1U}II*D(oOztFa&R>Q-{N8NUDe&dE6ZkjFTy~M(2az|>i&yObS4~N&&QMAI;e`i|!EHTYkPKMKQS9XHCk*tVS)9+QH zr9_eBdM>xpxpLWThdUPGP2}xV4!SbY+>Y&AB3K-#J+7#prJrxiOfH`{e_gCsL6p{3 zY>epUV%ztfOVIgwe&Ep~e%@ zMTx2Utc!Rrf6#e|Ee7&t$y=*!US$#+MBB&b6leDPYOC&w!?E|{P#3eM3-UmJa3FvH z0tg_000IagfB*srAb`N83h?~DsXI$v2q1s}0tg_000IagfB*srJP7RSOWyxKfN+HX z0tg_000IagfB*srAb`LI3h?~DftyN72q1s}0tg_000IagfB*srBn0^Xe*%Oi0tg_0 s00IagfB*srAb 0 { + stats.WithAlerts++ + } } return stats @@ -222,10 +229,13 @@ func (s *Store) GetStats() StoreStats { // StoreStats contains statistics about the resource store. type StoreStats struct { - TotalResources int - SuppressedResources int - ByType map[ResourceType]int - ByPlatform map[PlatformType]int + TotalResources int `json:"totalResources"` + SuppressedResources int `json:"suppressedResources"` + ByType map[ResourceType]int `json:"byType"` + ByPlatform map[PlatformType]int `json:"byPlatform"` + ByStatus map[ResourceStatus]int `json:"byStatus"` + WithAlerts int `json:"withAlerts"` + LastUpdated string `json:"lastUpdated"` } // GetPreferredResourceFor returns the preferred resource for a given ID.