diff --git a/.gemini/docs/unified-resource-architecture.md b/.gemini/docs/unified-resource-architecture.md index 3351c7a..fca645e 100644 --- a/.gemini/docs/unified-resource-architecture.md +++ b/.gemini/docs/unified-resource-architecture.md @@ -405,16 +405,32 @@ func (m *Monitor) pollPVENode(...) (models.Node, string, error) { **Goal:** Add a consolidated "All Resources" view for power users. -**Tasks:** -1. Create new React/Solid component for unified resource table -2. Implement filtering by platform, type, status, tags -3. Support hierarchical grouping (cluster > node > workloads) -4. Add as new optional view, don't replace existing pages +**Status:** ✅ Basic implementation complete -**User Experience:** -- New "All Resources" option in navigation -- Existing pages unchanged -- Users choose their preferred view +**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 + +**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 + +**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 + +**Access:** +Navigate to `/resources` directly (not yet in main navigation) ### Phase 5: New Platform Support (Ongoing) @@ -501,9 +517,10 @@ If we eventually want to migrate existing components: - [ ] Polling optimization actually used in live polling loops (optional enhancement) ### Phase 4 Complete When: -- [ ] Unified view accessible from UI -- [ ] Filtering and grouping works -- [ ] Existing pages still work +- [x] Unified view accessible from UI (route: `/resources`) +- [x] Filtering and grouping works +- [x] Existing pages still work +- [ ] Navigation link added to main UI (optional) --- diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 2a858cd..e8e0a38 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -67,6 +67,11 @@ 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; @@ -237,6 +242,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'), @@ -872,6 +878,7 @@ function App() { } /> + } /> diff --git a/frontend-modern/src/components/Resources/ResourcesOverview.tsx b/frontend-modern/src/components/Resources/ResourcesOverview.tsx new file mode 100644 index 0000000..f2c79e2 --- /dev/null +++ b/frontend-modern/src/components/Resources/ResourcesOverview.tsx @@ -0,0 +1,462 @@ +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 new file mode 100644 index 0000000..7643014 --- /dev/null +++ b/frontend-modern/src/types/resource.ts @@ -0,0 +1,161 @@ +// 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'; + } +}