diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index f788dcd0c..96d75ce22 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -41,7 +41,6 @@ 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'; import { startMetricsSampler } from './stores/metricsSampler'; import { seedFromBackend } from './stores/metricsHistory'; import { getMetricsViewMode } from './stores/metricsViewMode'; @@ -50,7 +49,6 @@ 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'; import NetworkIcon from 'lucide-solid/icons/network'; @@ -67,7 +65,6 @@ 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'; @@ -84,19 +81,11 @@ const AlertsPage = lazy(() => import('./pages/Alerts').then((module) => ({ default: module.Alerts })), ); const SettingsPage = lazy(() => import('./components/Settings/Settings')); -const DockerHosts = lazy(() => - import('./components/Docker/DockerHosts').then((module) => ({ default: module.DockerHosts })), -); const KubernetesClusters = lazy(() => import('./components/Kubernetes/KubernetesClusters').then((module) => ({ default: module.KubernetesClusters, })), ); -const HostsOverview = lazy(() => - import('./components/Hosts/HostsOverview').then((module) => ({ - default: module.HostsOverview, - })), -); const InfrastructurePage = lazy(() => import('./pages/Infrastructure')); const AIIntelligencePage = lazy(() => import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })), @@ -126,23 +115,6 @@ export const useDarkMode = () => { return context; }; -// Docker route component - uses unified resources via useResourcesAsLegacy hook -function DockerRoute() { - const wsContext = useContext(WebSocketContext); - if (!wsContext) { - return
Loading...
; - } - const { activeAlerts } = wsContext; - const { asDockerHosts } = useResourcesAsLegacy(); - - return ; -} - -// Hosts route component - HostsOverview uses useResourcesAsLegacy directly for proper reactivity -function HostsRoute() { - return ; -} - function KubernetesRoute() { const wsContext = useContext(WebSocketContext); if (!wsContext) { @@ -287,11 +259,8 @@ function App() { () => import('./components/Backups/Backups'), () => import('./components/Replication/Replication'), () => import('./components/PMG/MailGateway'), - () => import('./components/Hosts/HostsOverview'), - () => import('./pages/Alerts'), () => import('./components/Settings/Settings'), - () => import('./components/Docker/DockerHosts'), ]; loaders.forEach((load) => { @@ -848,17 +817,6 @@ 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 = () => ( ); @@ -1000,40 +958,21 @@ function App() { // Use Router with routes return ( - } /> - } /> - ( - - )} - /> + } /> + } /> - } /> + } /> - } /> + } /> - ( - - )} - /> - } /> - } /> + } /> @@ -1111,24 +1050,6 @@ function AppLayout(props: { const navigate = useNavigate(); const location = useLocation(); - const readSeenPlatforms = (): Record => { - if (typeof window === 'undefined') return {}; - try { - const stored = window.localStorage.getItem(STORAGE_KEYS.PLATFORMS_SEEN); - if (stored) { - const parsed = JSON.parse(stored) as Record; - if (parsed && typeof parsed === 'object') { - return parsed; - } - } - } catch (error) { - logger.warn('Failed to parse stored platform visibility preferences', error); - } - return {}; - }; - - const [seenPlatforms, setSeenPlatforms] = createSignal>(readSeenPlatforms()); - // Reactive kiosk mode state const [kioskMode, setKioskModeSignal] = createSignal(isKioskMode()); @@ -1160,26 +1081,6 @@ function AppLayout(props: { setKioskModeSignal(newValue); }; - const persistSeenPlatforms = (map: Record) => { - if (typeof window === 'undefined') return; - try { - window.localStorage.setItem(STORAGE_KEYS.PLATFORMS_SEEN, JSON.stringify(map)); - } catch (error) { - logger.warn('Failed to persist platform visibility preferences', error); - } - }; - - const markPlatformSeen = (platformId: string) => { - setSeenPlatforms((current) => { - if (current[platformId]) { - return current; - } - const updated = { ...current, [platformId]: true }; - persistSeenPlatforms(updated); - return updated; - }); - }; - // Determine active tab from current path const getActiveTab = () => { const path = location.pathname; @@ -1188,50 +1089,19 @@ function AppLayout(props: { 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('/proxmox/ceph') || path.startsWith('/proxmox/storage')) return 'storage'; + if (path.startsWith('/proxmox/replication') || path.startsWith('/proxmox/backups')) return 'backups'; + if (path.startsWith('/proxmox/mail')) return 'services'; + if (path.startsWith('/proxmox')) return 'infrastructure'; if (path.startsWith('/kubernetes')) return 'kubernetes'; - if (path.startsWith('/hosts')) return 'hosts'; - if (path.startsWith('/servers')) return 'hosts'; // Legacy redirect + if (path.startsWith('/servers')) return 'infrastructure'; if (path.startsWith('/alerts')) return 'alerts'; if (path.startsWith('/ai')) return 'ai'; if (path.startsWith('/settings')) return 'settings'; - return 'proxmox'; + return 'infrastructure'; }; - 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 || - (props.state().vms?.length ?? 0) > 0 || - (props.state().containers?.length ?? 0) > 0, - ); - - createEffect(() => { - if (hasDockerHosts()) { - markPlatformSeen('docker'); - } - }); - - createEffect(() => { - if (hasKubernetesClusters()) { - markPlatformSeen('kubernetes'); - } - }); - - createEffect(() => { - if (hasProxmoxHosts()) { - markPlatformSeen('proxmox'); - } - }); - - createEffect(() => { - if (hasHosts()) { - markPlatformSeen('hosts'); - } - }); type PlatformTab = { id: string; @@ -1313,34 +1183,6 @@ function AppLayout(props: { ), alwaysShow: false, }, - { - id: 'proxmox' as const, - label: 'Proxmox Overview', - route: '/proxmox/overview', - settingsRoute: '/settings', - 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: '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, label: 'Kubernetes', @@ -1354,20 +1196,6 @@ function AppLayout(props: { ), alwaysShow: false, // Only show when clusters exist }, - { - id: 'hosts' as const, - label: 'Hosts', - route: '/hosts', - settingsRoute: '/settings/host-agents', - tooltip: 'Legacy hosts view', - enabled: hasHosts() || !!seenPlatforms()['hosts'], - live: hasHosts(), - icon: ( - - ), - alwaysShow: true, // Hosts is commonly used, keep visible - badge: 'Legacy', - }, ]; // Filter out platforms that should be hidden when not configured diff --git a/frontend-modern/src/components/Backups/Backups.tsx b/frontend-modern/src/components/Backups/Backups.tsx index 770f90a83..106391426 100644 --- a/frontend-modern/src/components/Backups/Backups.tsx +++ b/frontend-modern/src/components/Backups/Backups.tsx @@ -3,7 +3,6 @@ import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { useWebSocket } from '@/App'; import UnifiedBackups from './UnifiedBackups'; -import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { useInstanceWarnings } from '@/hooks/useInstanceWarnings'; import AlertTriangle from 'lucide-solid/icons/alert-triangle'; @@ -31,8 +30,6 @@ const Backups: Component = () => { return (
- - {/* Permission Warnings Banner */} 0}> diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 503904844..27549881b 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -1200,6 +1200,7 @@ const UnifiedBackups: Component = () => { setIsSearchLocked(namespaceFilter !== ''); }} searchTerm={searchTerm()} + showNodeSummary={false} /> {/* PBS Enhancement Banner - shown when PBS storage exists via PVE but no direct PBS connection */} diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 3d400b154..7e87b4435 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -17,7 +17,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'; +import { SectionHeader } from '@/components/shared/SectionHeader'; import { isNodeOnline, OFFLINE_HEALTH_STATUSES, DEGRADED_HEALTH_STATUSES } from '@/utils/status'; import { getNodeDisplayName } from '@/utils/nodes'; import { logger } from '@/utils/logger'; @@ -215,6 +215,7 @@ export function Dashboard(props: DashboardProps) { const { isMobile } = useBreakpoint(); const alertsActivation = useAlertsActivation(); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); + const isWorkloadsRoute = () => location.pathname === '/workloads'; // Kiosk mode - hide filter panel for clean dashboard display // Usage: Add ?kiosk=1 to URL or use the toggle button in the header @@ -291,6 +292,9 @@ export function Dashboard(props: DashboardProps) { const v2Enabled = createMemo(() => props.useV2Workloads === true); const v2Workloads = useV2Workloads(v2Enabled); const v2Loaded = createMemo(() => v2Enabled() && !v2Workloads.loading() && !v2Workloads.error()); + const sortedNodes = createMemo(() => + [...props.nodes].sort((a, b) => getNodeDisplayName(a).localeCompare(getNodeDisplayName(b))), + ); const legacyGuests = createMemo(() => [ ...props.vms.map((vm) => ({ ...vm, workloadType: 'vm', displayId: String(vm.vmid) })), @@ -933,12 +937,40 @@ export function Dashboard(props: DashboardProps) { return (
- {/* Section nav - hidden in kiosk mode */} - - + + - {/* Unified Node Selector - always visible (this is the main dashboard content) */} + + +
+ + +
+
+
+ + {/* Unified Node Selector - infrastructure summary (hidden on workloads) */} resolveWorkloadType(g) === 'vm') as VM[]} filteredContainers={filteredGuests().filter((g) => resolveWorkloadType(g) === 'lxc') as Container[]} searchTerm={search()} + showNodeSummary={!isWorkloadsRoute()} /> {/* Dashboard Filter - hidden in kiosk mode */} diff --git a/frontend-modern/src/components/Docker/DockerClusterServicesTable.tsx b/frontend-modern/src/components/Docker/DockerClusterServicesTable.tsx deleted file mode 100644 index 031c54d9d..000000000 --- a/frontend-modern/src/components/Docker/DockerClusterServicesTable.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { Component, For, Show, createMemo, createSignal } from 'solid-js'; -import type { DockerHost } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { formatRelativeTime, getShortImageName } from '@/utils/format'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; -import { - groupHostsByCluster, - aggregateClusterServices, - formatNodeDistribution, - getServiceHealthStatus, - type SwarmCluster, - type ClusterService, -} from './swarmClusterHelpers'; - -interface DockerClusterServicesTableProps { - hosts: DockerHost[]; - searchTerm?: string; -} - -type SortKey = 'name' | 'stack' | 'mode' | 'replicas' | 'nodes' | 'status'; -type SortDirection = 'asc' | 'desc'; - -const SORT_KEYS: SortKey[] = ['name', 'stack', 'mode', 'replicas', 'nodes', 'status']; - -const SORT_DEFAULT_DIRECTION: Record = { - name: 'asc', - stack: 'asc', - mode: 'asc', - replicas: 'desc', - nodes: 'desc', - status: 'desc', -}; - -const STATUS_PRIORITY: Record = { - critical: 3, - degraded: 2, - healthy: 1, -}; - -const parseSearchTerm = (term?: string): string[] => { - if (!term) return []; - return term - .trim() - .toLowerCase() - .split(/\s+/) - .filter(Boolean); -}; - -const serviceMatchesSearch = (service: ClusterService, tokens: string[]): boolean => { - if (tokens.length === 0) return true; - - const searchableText = [ - service.service.name || '', - service.service.stack || '', - service.service.image || '', - service.service.mode || '', - ...service.nodes.map((n) => n.hostname), - ] - .join(' ') - .toLowerCase(); - - return tokens.every((token) => searchableText.includes(token)); -}; - -const ClusterGroupHeader: Component<{ - cluster: SwarmCluster; - serviceCount: number; - isExpanded: boolean; - onToggle: () => void; -}> = (props) => { - const managerCount = createMemo( - () => props.cluster.hosts.filter((h) => h.swarm?.nodeRole === 'manager').length - ); - const workerCount = createMemo( - () => props.cluster.hosts.filter((h) => h.swarm?.nodeRole === 'worker').length - ); - - return ( - - ); -}; - -const ServiceRow: Component<{ service: ClusterService }> = (props) => { - const healthStatus = createMemo(() => getServiceHealthStatus(props.service)); - - const statusVariant = createMemo(() => { - switch (healthStatus()) { - case 'critical': - return 'danger'; - case 'degraded': - return 'warning'; - default: - return 'success'; - } - }); - - const statusLabel = createMemo(() => { - const { totalDesired, totalRunning } = props.service; - if (totalDesired === 0) return 'No replicas'; - return `${totalRunning}/${totalDesired} running`; - }); - - const updatedAt = createMemo(() => { - const ts = props.service.service.updatedAt; - if (!ts) return null; - return typeof ts === 'number' ? ts : Date.parse(ts as unknown as string); - }); - - return ( - - {/* Service Name */} - -
- - {props.service.service.name} - - - - {getShortImageName(props.service.service.image)} - - -
- - - {/* Stack */} - - —}> - - {props.service.service.stack} - - - - - {/* Mode */} - - - {props.service.service.mode || 'replicated'} - - - - {/* Replicas */} - -
- - {statusLabel()} -
- - - {/* Nodes */} - - - {formatNodeDistribution(props.service.nodes)} - - - - {/* Updated */} - - —}> - - {formatRelativeTime(updatedAt()!)} - - - - - ); -}; - -const ClusterSection: Component<{ - cluster: SwarmCluster; - services: ClusterService[]; - sortKey: SortKey; - sortDirection: SortDirection; -}> = (props) => { - const [isExpanded, setIsExpanded] = createSignal(true); - - const sortedServices = createMemo(() => { - const services = [...props.services]; - const key = props.sortKey; - const dir = props.sortDirection; - - services.sort((a, b) => { - let cmp = 0; - - switch (key) { - case 'name': - cmp = (a.service.name || '').localeCompare(b.service.name || ''); - break; - case 'stack': - cmp = (a.service.stack || '').localeCompare(b.service.stack || ''); - break; - case 'mode': - cmp = (a.service.mode || '').localeCompare(b.service.mode || ''); - break; - case 'replicas': - cmp = a.totalRunning - b.totalRunning; - break; - case 'nodes': - cmp = a.nodes.length - b.nodes.length; - break; - case 'status': - cmp = - (STATUS_PRIORITY[getServiceHealthStatus(a)] || 0) - - (STATUS_PRIORITY[getServiceHealthStatus(b)] || 0); - break; - } - - return dir === 'asc' ? cmp : -cmp; - }); - - return services; - }); - - return ( -
- setIsExpanded(!isExpanded())} - /> - - -
- - - {(service) => } - -
-
-
-
- ); -}; - -export const DockerClusterServicesTable: Component = (props) => { - const [sortKey, setSortKey] = usePersistentSignal( - 'dockerClusterSortKey', - 'name', - { deserialize: (v) => (SORT_KEYS.includes(v as SortKey) ? (v as SortKey) : 'name') } - ); - const [sortDirection, setSortDirection] = usePersistentSignal( - 'dockerClusterSortDirection', - 'asc', - { deserialize: (v) => (v === 'asc' || v === 'desc' ? v : 'asc') } - ); - - const handleSort = (key: SortKey) => { - if (sortKey() === key) { - setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); - } else { - setSortKey(key); - setSortDirection(SORT_DEFAULT_DIRECTION[key]); - } - }; - - const renderSortIndicator = (key: SortKey) => { - if (sortKey() !== key) return null; - return sortDirection() === 'asc' ? ' ▲' : ' ▼'; - }; - - const searchTokens = createMemo(() => parseSearchTerm(props.searchTerm)); - - const clusters = createMemo(() => groupHostsByCluster(props.hosts)); - - const clustersWithServices = createMemo(() => { - const tokens = searchTokens(); - - return clusters() - .map((cluster) => { - const allServices = aggregateClusterServices(cluster); - const filteredServices = allServices.filter((s) => serviceMatchesSearch(s, tokens)); - return { cluster, services: filteredServices }; - }) - .filter((c) => c.services.length > 0); - }); - - const totalServices = createMemo(() => - clustersWithServices().reduce((sum, c) => sum + c.services.length, 0) - ); - - return ( - - 0} - fallback={ - - - - } - /> - } - > - {/* Header row */} -
- - - - - - - - - - - -
handleSort('name')} - > - Service{renderSortIndicator('name')} - handleSort('stack')} - > - Stack{renderSortIndicator('stack')} - handleSort('mode')} - > - Mode{renderSortIndicator('mode')} - handleSort('replicas')} - > - Replicas{renderSortIndicator('replicas')} - handleSort('nodes')} - > - Nodes{renderSortIndicator('nodes')} - Updated
-
- - {/* Cluster sections */} -
- - {({ cluster, services }) => ( - - )} - -
- - {/* Footer summary */} -
- {clustersWithServices().length} cluster{clustersWithServices().length !== 1 ? 's' : ''},{' '} - {totalServices()} service{totalServices() !== 1 ? 's' : ''} -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Docker/DockerFilter.tsx b/frontend-modern/src/components/Docker/DockerFilter.tsx deleted file mode 100644 index 5693a9470..000000000 --- a/frontend-modern/src/components/Docker/DockerFilter.tsx +++ /dev/null @@ -1,568 +0,0 @@ -import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js'; -import { Card } from '@/components/shared/Card'; -import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover'; -import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { createSearchHistoryManager } from '@/utils/searchHistory'; - -export type DockerViewMode = 'grouped' | 'flat' | 'cluster'; - -interface DockerFilterProps { - search: () => string; - setSearch: (value: string) => void; - groupingMode?: () => DockerViewMode; - setGroupingMode?: (mode: DockerViewMode) => void; - hasSwarmClusters?: boolean; - statusFilter?: () => 'all' | 'online' | 'degraded' | 'offline'; - setStatusFilter?: (value: 'all' | 'online' | 'degraded' | 'offline') => void; - searchInputRef?: (el: HTMLInputElement) => void; - onReset?: () => void; - activeHostName?: string; - onClearHost?: () => void; - updateAvailableCount?: number; - onUpdateAll?: () => void; - onCheckUpdates?: (hostId: string) => void; - activeHostId?: string | null; - checkingUpdatesStatus?: string; // 'queued' | 'dispatched' | 'acknowledged' | 'in_progress' | undefined -} - -const UpdateAllButton: Component<{ count: number; onUpdate: () => void }> = (props) => { - const [confirming, setConfirming] = createSignal(false); - - return ( - - ); -}; - -const RefreshButton: Component<{ onRefresh: () => void }> = (props) => { - const [loading, setLoading] = createSignal(false); - - const handleClick = async () => { - if (loading()) return; - setLoading(true); - try { - await props.onRefresh(); - } finally { - // Keep it loading for a bit to show it happened - setTimeout(() => setLoading(false), 1000); - } - }; - - return ( - - ); -}; - - -export const DockerFilter: Component = (props) => { - const historyManager = createSearchHistoryManager(STORAGE_KEYS.DOCKER_SEARCH_HISTORY); - const [searchHistory, setSearchHistory] = createSignal([]); - const [isHistoryOpen, setIsHistoryOpen] = createSignal(false); - - let searchInputEl: HTMLInputElement | undefined; - let historyMenuRef: HTMLDivElement | undefined; - let historyToggleRef: HTMLButtonElement | undefined; - - onMount(() => { - setSearchHistory(historyManager.read()); - }); - - const commitSearchToHistory = (term: string) => { - const trimmed = term.trim(); - if (!trimmed) return; - const updated = historyManager.add(trimmed); - setSearchHistory(updated); - }; - - const deleteHistoryEntry = (term: string) => { - setSearchHistory(historyManager.remove(term)); - }; - - const clearHistory = () => { - setSearchHistory(historyManager.clear()); - setIsHistoryOpen(false); - queueMicrotask(() => historyToggleRef?.blur()); - }; - - const closeHistory = () => { - setIsHistoryOpen(false); - queueMicrotask(() => historyToggleRef?.blur()); - }; - - const handleDocumentClick = (event: MouseEvent) => { - const target = event.target as Node; - const clickedMenu = historyMenuRef?.contains(target) ?? false; - const clickedToggle = historyToggleRef?.contains(target) ?? false; - if (!clickedMenu && !clickedToggle) { - closeHistory(); - } - }; - - createEffect(() => { - if (isHistoryOpen()) { - document.addEventListener('mousedown', handleDocumentClick); - } else { - document.removeEventListener('mousedown', handleDocumentClick); - } - }); - - onCleanup(() => { - document.removeEventListener('mousedown', handleDocumentClick); - }); - - const focusSearchInput = () => { - queueMicrotask(() => searchInputEl?.focus()); - }; - - let suppressBlurCommit = false; - - const markSuppressCommit = () => { - suppressBlurCommit = true; - queueMicrotask(() => { - suppressBlurCommit = false; - }); - }; - - const hasActiveFilters = createMemo( - () => - props.search().trim() !== '' || - (!!props.groupingMode && props.groupingMode() !== 'grouped') || - (!!props.statusFilter && props.statusFilter() !== 'all') || - Boolean(props.activeHostName), - ); - - const handleReset = () => { - props.setSearch(''); - props.setGroupingMode?.('grouped'); - props.setStatusFilter?.('all'); - props.onClearHost?.(); - props.onReset?.(); - closeHistory(); - focusSearchInput(); - }; - - return ( - -
- {/* Search - full width on its own row */} -
- { - searchInputEl = el; - props.searchInputRef?.(el); - }} - type="text" - placeholder="Search containers or host:hostname" - value={props.search()} - onInput={(e) => props.setSearch(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitSearchToHistory(e.currentTarget.value); - closeHistory(); - } else if (e.key === 'Escape') { - props.setSearch(''); - closeHistory(); - e.currentTarget.blur(); - } else if (e.key === 'ArrowDown' && searchHistory().length > 0) { - e.preventDefault(); - setIsHistoryOpen(true); - } - }} - onBlur={(e) => { - if (suppressBlurCommit) return; - const next = e.relatedTarget as HTMLElement | null; - const interactingWithHistory = next - ? historyMenuRef?.contains(next) || historyToggleRef?.contains(next) - : false; - const interactingWithTips = - next?.getAttribute('aria-controls') === 'container-search-help'; - if (!interactingWithHistory && !interactingWithTips) { - commitSearchToHistory(e.currentTarget.value); - } - }} - class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" - title="Search containers by name, image, ID, or host" - /> - - - - - - -
- - -
- -
(historyMenuRef = el)} - class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800" - role="listbox" - > - 0} - fallback={ -
- Searches you run will appear here. -
- } - > -
- - {(entry) => ( -
- - -
- )} -
-
- -
-
-
-
- - {/* Filters - second row */} -
- -
- - - - -
-
- - -
- - - - - -
-
- - -
- Host: {props.activeHostName} - -
-
- - {/* Metrics View Toggle */} - {/* Metrics View Toggle */} - - - 1}> - - - - - - - - -
- - - - Checking updates... -
-
- - props.onCheckUpdates!(props.activeHostId!)} - /> - -
- - - - - - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx deleted file mode 100644 index a3dee7ce6..000000000 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { Component, For, Show, createMemo, createSignal } from 'solid-js'; -import type { DockerHost } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { resolveHostRuntime } from './runtimeDisplay'; -import { formatUptime } from '@/utils/format'; -import { ScrollableTable } from '@/components/shared/ScrollableTable'; -import { buildMetricKey } from '@/utils/metricsKeys'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { getDockerHostStatusIndicator } from '@/utils/status'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { ResponsiveMetricCell } from '@/components/shared/responsive'; -import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; -import { isAgentOutdated, getAgentVersionTooltip } from '@/utils/agentVersion'; - - -export interface DockerHostSummary { - host: DockerHost; - cpuPercent: number; - memoryPercent: number; - memoryLabel?: string; - diskPercent: number; - diskLabel?: string; - runningPercent: number; - runningCount: number; - stoppedCount: number; - errorCount: number; - totalCount: number; - uptimeSeconds: number; - lastSeenRelative: string; - lastSeenAbsolute: string; -} - -interface DockerHostSummaryTableProps { - summaries: () => DockerHostSummary[]; - selectedHostId: () => string | null; - onSelect: (hostId: string) => void; -} - -type SortKey = 'name' | 'uptime' | 'cpu' | 'memory' | 'disk' | 'running' | 'lastSeen' | 'agent'; - -type SortDirection = 'asc' | 'desc'; - -const isHostOnline = (host: DockerHost) => { - const status = host.status?.toLowerCase() ?? ''; - return status === 'online' || status === 'running' || status === 'healthy' || status === 'degraded'; -}; - -const getDisplayName = (host: DockerHost) => { - return host.customDisplayName || host.displayName || host.hostname || host.id; -}; - -export const DockerHostSummaryTable: Component = (props) => { - const [sortKey, setSortKey] = createSignal('name'); - const [sortDirection, setSortDirection] = createSignal('asc'); - const { isMobile } = useBreakpoint(); - - const handleSort = (key: SortKey) => { - if (sortKey() === key) { - setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); - } else { - setSortKey(key); - setSortDirection(key === 'name' ? 'asc' : 'desc'); - } - }; - - const formatLastSeenValue = (lastSeen?: number) => { - if (!lastSeen) return 0; - return lastSeen; - }; - - const sortedSummaries = createMemo(() => { - const list = [...props.summaries()]; - const key = sortKey(); - const dir = sortDirection(); - - list.sort((a, b) => { - const hostA = a.host; - const hostB = b.host; - - let value = 0; - switch (key) { - case 'name': - value = getDisplayName(hostA).localeCompare(getDisplayName(hostB)); - break; - case 'uptime': - value = (a.uptimeSeconds || 0) - (b.uptimeSeconds || 0); - break; - case 'cpu': - value = a.cpuPercent - b.cpuPercent; - break; - case 'memory': - value = a.memoryPercent - b.memoryPercent; - break; - case 'disk': - value = a.diskPercent - b.diskPercent; - break; - case 'running': - value = a.runningPercent - b.runningPercent; - break; - case 'lastSeen': - value = formatLastSeenValue(hostA.lastSeen) - formatLastSeenValue(hostB.lastSeen); - break; - case 'agent': - // Sort by version, putting outdated versions first (for easy identification) - const aOutdated = isAgentOutdated(hostA.agentVersion); - const bOutdated = isAgentOutdated(hostB.agentVersion); - if (aOutdated !== bOutdated) { - value = aOutdated ? -1 : 1; - } else { - value = (hostA.agentVersion || '').localeCompare(hostB.agentVersion || ''); - } - break; - } - - if (value === 0) { - value = getDisplayName(hostA).localeCompare(getDisplayName(hostB)); - } - - return dir === 'asc' ? value : -value; - }); - - return list; - }); - - const renderSortIndicator = (key: SortKey) => { - if (sortKey() !== key) return null; - return sortDirection() === 'asc' ? '▲' : '▼'; - }; - - // Agent version checking is now done via the shared utility that compares against server version - - return ( - <> - - - - - - - - - - - - - - - - - - {(summary) => { - const selected = props.selectedHostId() === summary.host.id; - const online = isHostOnline(summary.host); - const uptimeLabel = summary.uptimeSeconds ? formatUptime(summary.uptimeSeconds) : '—'; - - const rowStyle = () => { - const styles: Record = {}; - const shadows: string[] = []; - - if (selected) { - shadows.push('0 0 0 1px rgba(59, 130, 246, 0.5)'); - shadows.push('0 2px 4px -1px rgba(0, 0, 0, 0.1)'); - } - - if (shadows.length > 0) { - styles['box-shadow'] = shadows.join(', '); - } - return styles; - }; - - const rowClass = () => { - const baseHover = 'group cursor-pointer transition-all duration-200 relative hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:shadow-sm'; - - if (selected) { - return 'group cursor-pointer transition-all duration-200 relative bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 hover:shadow-sm z-10'; - } - - let className = baseHover; - - if (!online) { - className += ' opacity-60'; - } - - return className; - }; - - const agentOutdated = isAgentOutdated(summary.host.agentVersion); - const runtimeInfo = resolveHostRuntime(summary.host); - const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; - const metricsKey = buildMetricKey('dockerHost', summary.host.id); - const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host)); - - return ( - props.onSelect(summary.host.id)} - > - - - - - - - - - - ); - }} - - -
handleSort('name')} - onKeyDown={(e) => e.key === 'Enter' && handleSort('name')} - tabIndex={0} - role="button" - aria-label={`Sort by host ${sortKey() === 'name' ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - > - Host {renderSortIndicator('name')} - handleSort('uptime')} - > - Uptime {renderSortIndicator('uptime')} - handleSort('cpu')} - > - CPU {renderSortIndicator('cpu')} - handleSort('memory')} - > - Memory {renderSortIndicator('memory')} - handleSort('disk')} - > - Disk {renderSortIndicator('disk')} - handleSort('running')} - > - Containers {renderSortIndicator('running')} - handleSort('lastSeen')} - > - Last Update {renderSortIndicator('lastSeen')} - handleSort('agent')} - > - Agent {renderSortIndicator('agent')} -
-
- - - {getDisplayName(summary.host)} - - - - - -
-
-
- - {uptimeLabel} - -
-
-
- -
-
-
- -
-
-
- -
-
-
- 0} - fallback={} - > - - {summary.totalCount} - - -
-
-
- —} - > - {(relative) => ( - - {relative()} - - )} - -
-
-
- —} - > - {(version) => ( - - {version()} - - )} - - - - ⚠ - - -
-
-
-
- - ); -}; diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx deleted file mode 100644 index 1d22b6383..000000000 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ /dev/null @@ -1,526 +0,0 @@ -import type { Component } from 'solid-js'; -import { Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js'; -import { createStore } from 'solid-js/store'; -import { useNavigate } from '@solidjs/router'; -import type { DockerHost } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { DockerFilter, type DockerViewMode } from './DockerFilter'; -import { DockerHostSummaryTable, type DockerHostSummary } from './DockerHostSummaryTable'; -import { DockerUnifiedTable } from './DockerUnifiedTable'; -import { DockerClusterServicesTable } from './DockerClusterServicesTable'; -import { hasSwarmClusters } from './swarmClusterHelpers'; -import { useWebSocket } from '@/App'; -import { useDebouncedValue } from '@/hooks/useDebouncedValue'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; -import { formatBytes, formatRelativeTime } from '@/utils/format'; - -import { logger } from '@/utils/logger'; - -import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status'; -import { MonitoringAPI } from '@/api/monitoring'; -import { showSuccess, showError, showToast } from '@/utils/toast'; -import { isKioskMode, subscribeToKioskMode } from '@/utils/url'; - - - -interface DockerHostsProps { - hosts: DockerHost[]; - activeAlerts?: Record | any; -} - -export const DockerHosts: Component = (props) => { - const navigate = useNavigate(); - const { initialDataReceived, reconnecting, connected, reconnect } = useWebSocket(); - - // Load docker metadata from localStorage or API - - - const sortedHosts = createMemo(() => { - const hosts = props.hosts || []; - return [...hosts].sort((a, b) => { - const aName = a.customDisplayName || a.displayName || a.hostname || a.id || ''; - const bName = b.customDisplayName || b.displayName || b.hostname || b.id || ''; - return aName.localeCompare(bName); - }); - }); - - const isLoading = createMemo(() => { - if (typeof initialDataReceived === 'function') { - const hostCount = Array.isArray(props.hosts) ? props.hosts.length : 0; - return !initialDataReceived() && hostCount === 0; - } - return false; - }); - - const [search, setSearch] = createSignal(''); - const debouncedSearch = useDebouncedValue(search, 250); - const [selectedHostId, setSelectedHostId] = createSignal(null); - const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); - const [groupingMode, setGroupingMode] = usePersistentSignal('dockerGroupingMode', 'grouped', { - deserialize: (v) => (['grouped', 'flat', 'cluster'].includes(v) ? v as DockerViewMode : 'grouped'), - }); - - // Detect if any Swarm clusters exist (2+ hosts sharing a clusterId) - const hasSwarmClustersDetected = createMemo(() => hasSwarmClusters(sortedHosts())); - - // Kiosk mode - hide filter panel for clean dashboard display - const [kioskMode, setKioskMode] = createSignal(isKioskMode()); - - // Subscribe to kiosk mode changes from toggle button - onMount(() => { - const unsubscribe = subscribeToKioskMode((enabled) => { - setKioskMode(enabled); - }); - return unsubscribe; - }); - - const clampPercent = (value: number | undefined | null) => { - if (value === undefined || value === null || Number.isNaN(value)) return 0; - if (!Number.isFinite(value)) return 0; - if (value < 0) return 0; - if (value > 100) return 100; - return value; - }; - - // Cache for stable summary objects to prevent re-animations - const summaryCache = new Map(); - - const hostSummaries = createMemo(() => { - const usedKeys = new Set(); - - const result = sortedHosts().map((host) => { - const totalContainers = host.containers?.length ?? 0; - const runningContainers = - host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0; - const stoppedContainers = - host.containers?.filter((container) => - ['exited', 'stopped', 'created'].includes(container.state?.toLowerCase() || '') - ).length ?? 0; - // Count anything that isn't running or stopped/created as an error/warning state (restarting, dead, paused, etc) - const errorContainers = totalContainers - runningContainers - stoppedContainers; - - const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0; - - const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0); - - const memoryUsed = host.memory?.used ?? 0; - const memoryTotal = host.memory?.total ?? host.totalMemoryBytes ?? 0; - const memoryPercent = host.memory?.usage - ? clampPercent(host.memory.usage) - : memoryTotal > 0 - ? clampPercent((memoryUsed / memoryTotal) * 100) - : 0; - const memoryLabel = - memoryTotal > 0 ? `${formatBytes(memoryUsed)} / ${formatBytes(memoryTotal)}` : undefined; - - let diskPercent = 0; - let diskLabel: string | undefined; - if (host.disks && host.disks.length > 0) { - const totals = host.disks.reduce( - (acc, disk) => { - acc.used += disk.used ?? 0; - acc.total += disk.total ?? 0; - return acc; - }, - { used: 0, total: 0 }, - ); - if (totals.total > 0) { - diskPercent = clampPercent((totals.used / totals.total) * 100); - diskLabel = `${formatBytes(totals.used)} / ${formatBytes(totals.total)}`; - } - } - - const uptimeSeconds = host.uptimeSeconds ?? 0; - const lastSeenRelative = host.lastSeen ? formatRelativeTime(host.lastSeen) : '—'; - const lastSeenAbsolute = host.lastSeen ? new Date(host.lastSeen).toLocaleString() : ''; - - const newSummary: DockerHostSummary = { - host, - cpuPercent, - memoryPercent, - memoryLabel, - diskPercent, - diskLabel, - runningPercent, - runningCount: runningContainers, - stoppedCount: stoppedContainers, - errorCount: errorContainers, - totalCount: totalContainers, - uptimeSeconds, - lastSeenRelative, - lastSeenAbsolute, - }; - - const key = host.id; - usedKeys.add(key); - - let entry = summaryCache.get(key); - if (!entry) { - entry = createStore(newSummary); - summaryCache.set(key, entry); - } else { - const [_, setState] = entry; - setState(newSummary); - } - return entry[0]; - }); - - // Prune cache - for (const key of summaryCache.keys()) { - if (!usedKeys.has(key)) { - summaryCache.delete(key); - } - } - - return result; - }); - - let searchInputRef: HTMLInputElement | undefined; - - const focusSearchInput = () => { - queueMicrotask(() => searchInputRef?.focus()); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement; - - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } - - if (event.ctrlKey || event.metaKey || event.altKey) { - return; - } - - if (event.key.length === 1 && searchInputRef) { - event.preventDefault(); - focusSearchInput(); - setSearch((prev) => prev + event.key); - } - }; - - onMount(() => { - document.addEventListener('keydown', handleKeyDown); - - - }); - onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); - - - - - - createEffect(() => { - const hostId = selectedHostId(); - if (!hostId) { - return; - } - if (!sortedHosts().some((host) => host.id === hostId)) { - setSelectedHostId(null); - } - }); - - const hostMatchesStatus = (host: DockerHost) => { - const status = statusFilter(); - if (status === 'all') return true; - const normalized = host.status?.toLowerCase() ?? ''; - if (status === 'online') return normalized === 'online'; - if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized); - if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized); - return true; - }; - - const filteredHostSummaries = createMemo(() => { - const summaries = hostSummaries(); - if (statusFilter() === 'all') return summaries; - return summaries.filter((summary) => hostMatchesStatus(summary.host)); - }); - - createEffect(() => { - const hostId = selectedHostId(); - if (!hostId) return; - if (!filteredHostSummaries().some((summary) => summary.host.id === hostId)) { - setSelectedHostId(null); - } - }); - - const statsFilter = createMemo(() => { - const status = statusFilter(); - if (status === 'all') return null; - return { type: 'host-status' as const, value: status }; - }); - - const handleHostSelect = (hostId: string) => { - setSelectedHostId((current) => (current === hostId ? null : hostId)); - }; - - const updateableContainers = createMemo(() => { - const containers: { hostId: string; containerId: string; containerName: string }[] = []; - sortedHosts().forEach((host) => { - if (!hostMatchesStatus(host)) return; - host.containers?.forEach((c) => { - if (c.updateStatus?.updateAvailable) { - containers.push({ - hostId: host.id, - containerId: c.id, - containerName: c.name || c.id, - }); - } - }); - }); - return containers; - }); - - // Track batch update status: key is hostId:containerId - const [batchUpdateState, setBatchUpdateState] = createStore>({}); - - const handleUpdateAll = async () => { - const targets = updateableContainers(); - if (targets.length === 0) return; - - // Initial toast - showToast( - 'info', - 'Batch Update Started', - `Preparing to update ${targets.length} containers...`, - 10000, - ); - - // Mark all as updating - targets.forEach(t => { - setBatchUpdateState(`${t.hostId}:${t.containerId}`, 'updating'); - }); - - let successCount = 0; - let failCount = 0; - - // Process in chunks of 5 to avoid overloading the browser/network - const chunkSize = 5; - for (let i = 0; i < targets.length; i += chunkSize) { - const chunk = targets.slice(i, i + chunkSize); - - await Promise.all(chunk.map(async (target) => { - const key = `${target.hostId}:${target.containerId}`; - try { - await MonitoringAPI.updateDockerContainer( - target.hostId, - target.containerId, - target.containerName, - ); - setBatchUpdateState(key, 'queued'); - successCount++; - } catch (err) { - failCount++; - setBatchUpdateState(key, 'error'); - logger.error(`Failed to trigger update for ${target.containerName}`, err); - } - })); - } - - if (failCount === 0) { - showSuccess(`Successfully queued updates for all ${targets.length} containers.`); - // Clear success states after delay - setTimeout(() => { - targets.forEach(t => { - const key = `${t.hostId}:${t.containerId}`; - if (batchUpdateState[key] === 'queued') { - setBatchUpdateState(key, undefined as any); - } - }); - }, 5000); - } else if (successCount === 0) { - showError(`Failed to queue any updates. Check console for details.`); - } else { - showToast('warning', 'Batch Update Completed', `Queued ${successCount} updates. ${failCount} failed.`); - } - }; - - const handleCheckUpdates = async (hostId: string) => { - try { - await MonitoringAPI.checkDockerUpdates(hostId); - showSuccess('Update check triggered. The host will refresh container information shortly.'); - } catch (err) { - showError(`Failed to trigger update check: ${err instanceof Error ? err.message : String(err)}`); - } - }; - - // Get the command status for the selected host to show checking indicator - const selectedHostCommandStatus = createMemo(() => { - const hostId = selectedHostId(); - if (!hostId) return undefined; - - const host = props.hosts.find(h => h.id === hostId); - if (!host?.command) return undefined; - - // Only show status for check_updates commands - if (host.command.type !== 'check_updates') return undefined; - - return host.command.status; - }); - - const renderFilter = () => ( - { - setSearch(''); - setSelectedHostId(null); - setStatusFilter('all'); - setGroupingMode('grouped'); - }} - searchInputRef={(el) => { - searchInputRef = el; - }} - updateAvailableCount={updateableContainers().length} - onUpdateAll={handleUpdateAll} - onCheckUpdates={handleCheckUpdates} - activeHostId={selectedHostId()} - checkingUpdatesStatus={selectedHostCommandStatus()} - /> - ); - - - return ( -
- - - - - - - } - title={reconnecting() ? 'Reconnecting to container agents...' : 'Loading container data...'} - description={ - reconnecting() - ? 'Re-establishing metrics from the monitoring service.' - : connected() - ? 'Waiting for the first container update.' - : 'Connecting to the monitoring service.' - } - /> - - - - {/* Disconnected State */} - - - - - - } - title="Connection lost" - description={ - reconnecting() - ? 'Attempting to reconnect…' - : 'Unable to connect to the backend server' - } - tone="danger" - actions={ - !reconnecting() ? ( - - ) : undefined - } - /> - - - - - 0} - fallback={ - <> - {renderFilter()} - - - - - } - title="No container runtimes reporting" - description="Deploy the Pulse container agent (Docker or Podman) on at least one host to light up this tab. As soon as an agent reports in, runtime metrics appear automatically." - actions={ - - } - /> - - - } - > - 0}> - - - - {renderFilter()} - - - } - > - - - - -
- ); -}; diff --git a/frontend-modern/src/components/Docker/DockerStatusBadge.tsx b/frontend-modern/src/components/Docker/DockerStatusBadge.tsx deleted file mode 100644 index ff1b608e8..000000000 --- a/frontend-modern/src/components/Docker/DockerStatusBadge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { JSX } from 'solid-js'; - -const badgeClasses = ( - status: string, - healthyClass: string, - warningClass: string, - dangerClass: string, -) => { - switch (status.toLowerCase()) { - case 'online': - case 'running': - case 'healthy': - return healthyClass; - case 'offline': - case 'exited': - case 'failed': - case 'unhealthy': - return dangerClass; - default: - return warningClass; - } -}; - -export const renderDockerStatusBadge = (status?: string): JSX.Element => { - const value = (status || 'unknown').toLowerCase(); - return ( - - {status || 'unknown'} - - ); -}; diff --git a/frontend-modern/src/components/Docker/DockerSummaryStats.tsx b/frontend-modern/src/components/Docker/DockerSummaryStats.tsx deleted file mode 100644 index cd113c330..000000000 --- a/frontend-modern/src/components/Docker/DockerSummaryStats.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import type { Component } from 'solid-js'; -import { Show, createMemo } from 'solid-js'; -import type { DockerHost } from '@/types/api'; - -interface StatCardProps { - label: string; - value: number | string; - sublabel?: string; - variant?: 'default' | 'success' | 'warning' | 'error' | 'info'; - onClick?: () => void; - isActive?: boolean; -} - -const StatCard: Component = (props) => { - const baseClass = 'flex flex-col gap-1 px-4 py-3 rounded-lg border transition-all duration-200'; - - const variantClass = () => { - if (props.isActive) { - return 'bg-blue-100 dark:bg-blue-900/40 border-blue-300 dark:border-blue-700 ring-2 ring-blue-500/20'; - } - - switch (props.variant) { - case 'success': - return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'; - case 'warning': - return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800'; - case 'error': - return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'; - case 'info': - return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800'; - default: - return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'; - } - }; - - const hoverClass = () => props.onClick ? 'cursor-pointer hover:shadow-md hover:scale-[1.02]' : ''; - - return ( - - ); -}; - -export interface DockerSummaryStats { - hosts: { - total: number; - online: number; - degraded: number; - offline: number; - }; - containers: { - total: number; - running: number; - degraded: number; - stopped: number; - error: number; - }; - services?: { - total: number; - healthy: number; - degraded: number; - }; - resources?: { - avgCpu: number | null; - avgMemory: number | null; - }; -} - -type SummaryFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null; - -interface DockerSummaryStatsProps { - hosts: DockerHost[]; - onFilterChange?: (filter: SummaryFilter) => void; - activeFilter?: { type: string; value: string } | null; -} - -export const DockerSummaryStatsBar: Component = (props) => { - const stats = (): DockerSummaryStats => { - const hosts = props.hosts || []; - - // Host stats - let onlineHosts = 0; - let degradedHosts = 0; - let offlineHosts = 0; - - hosts.forEach((host) => { - const status = host.status?.toLowerCase() ?? 'unknown'; - switch (status) { - case 'online': - onlineHosts++; - break; - case 'degraded': - case 'warning': - case 'maintenance': - degradedHosts++; - break; - case 'offline': - case 'error': - case 'unreachable': - offlineHosts++; - break; - default: - degradedHosts++; - break; - } - }); - - // Container stats - let totalContainers = 0; - let runningContainers = 0; - let degradedContainers = 0; - let stoppedContainers = 0; - let errorContainers = 0; - - // Service stats - let totalServices = 0; - let healthyServices = 0; - let degradedServices = 0; - - // Resource stats - let totalCpu = 0; - let totalMemory = 0; - let cpuSamples = 0; - let memorySamples = 0; - - hosts.forEach(host => { - // Count containers - const containers = host.containers || []; - totalContainers += containers.length; - - containers.forEach(container => { - const state = container.state?.toLowerCase(); - const health = container.health?.toLowerCase(); - - if (state === 'running') { - if (health === 'unhealthy') { - degradedContainers++; - } else { - runningContainers++; - } - - // Sum CPU/Memory for running containers - if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) { - totalCpu += container.cpuPercent; - cpuSamples++; - } - if (typeof container.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent)) { - totalMemory += container.memoryPercent; - memorySamples++; - } - } else if (state === 'exited' || state === 'stopped' || state === 'created' || state === 'paused') { - stoppedContainers++; - } else if (state === 'restarting' || state === 'dead' || state === 'removing' || state === 'error' || state === 'failed') { - errorContainers++; - } - }); - - // Count services - const services = host.services || []; - totalServices += services.length; - - services.forEach(service => { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - if (desired > 0 && running >= desired) { - healthyServices++; - } else if (desired > 0) { - degradedServices++; - } - }); - }); - - return { - hosts: { - total: hosts.length, - online: onlineHosts, - degraded: degradedHosts, - offline: offlineHosts, - }, - containers: { - total: totalContainers, - running: runningContainers, - degraded: degradedContainers, - stopped: stoppedContainers, - error: errorContainers, - }, - services: totalServices > 0 ? { - total: totalServices, - healthy: healthyServices, - degraded: degradedServices, - } : undefined, - resources: (cpuSamples > 0 || memorySamples > 0) ? { - avgCpu: cpuSamples > 0 ? totalCpu / cpuSamples : null, - avgMemory: memorySamples > 0 ? totalMemory / memorySamples : null, - } : undefined, - }; - }; - - const summary = createMemo(stats); - - const hostSublabel = () => { - const hosts = summary().hosts; - const parts = [`${hosts.online} online`]; - if (hosts.degraded > 0) { - parts.push(`${hosts.degraded} degraded`); - } - if (hosts.offline > 0) { - parts.push(`${hosts.offline} offline`); - } - return parts.join(', '); - }; - - const servicesSublabel = () => { - const services = summary().services; - if (!services) return ''; - const parts = [`${services.healthy} healthy`]; - if (services.degraded > 0) { - parts.push(`${services.degraded} degraded`); - } - return parts.join(', '); - }; - - const resourceValue = () => { - const avgCpu = summary().resources?.avgCpu ?? null; - return avgCpu !== null ? Math.round(avgCpu) : '—'; - }; - - const resourceSublabel = () => { - const avgMemory = summary().resources?.avgMemory ?? null; - if (avgMemory === null) return undefined; - return `${Math.round(avgMemory)}% mem`; - }; - - const resourceVariant = (): StatCardProps['variant'] => { - const avgCpu = summary().resources?.avgCpu ?? null; - if (avgCpu === null) return 'info'; - if (avgCpu > 80) return 'error'; - if (avgCpu > 60) return 'warning'; - return 'info'; - }; - - const isActive = (type: string, value: string) => { - return props.activeFilter?.type === type && props.activeFilter?.value === value; - }; - - return ( -
-
-

- Overview -

- - - -
- -
- {/* Hosts */} - - - 0}> - props.onFilterChange?.({ type: 'host-status', value: 'degraded' })} - isActive={isActive('host-status', 'degraded')} - /> - - - 0}> - props.onFilterChange?.({ type: 'host-status', value: 'offline' })} - isActive={isActive('host-status', 'offline')} - /> - - - {/* Containers */} - props.onFilterChange?.({ type: 'container-state', value: 'running' })} - isActive={isActive('container-state', 'running')} - /> - - 0}> - props.onFilterChange?.({ type: 'container-state', value: 'degraded' })} - isActive={isActive('container-state', 'degraded')} - /> - - - 0}> - props.onFilterChange?.({ type: 'container-state', value: 'stopped' })} - isActive={isActive('container-state', 'stopped')} - /> - - - 0}> - props.onFilterChange?.({ type: 'container-state', value: 'error' })} - isActive={isActive('container-state', 'error')} - /> - - - {/* Services */} - - 0 ? 'warning' : 'success'} - /> - - - {/* Resources */} - - - -
-
- ); -}; diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx deleted file mode 100644 index 6f09e0902..000000000 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ /dev/null @@ -1,2567 +0,0 @@ -import { Component, For, Show, createMemo, createSignal, createEffect, Accessor, onMount } from 'solid-js'; -import type { DockerHost, DockerContainer, DockerService, DockerTask } from '@/types/api'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { MetricBar } from '@/components/Dashboard/MetricBar'; -import { - formatPercent, - formatBytes, - formatUptime, - formatRelativeTime, - getShortImageName, - formatAbsoluteTime, -} from '@/utils/format'; -import { resolveHostRuntime } from './runtimeDisplay'; - -// ... (other imports remain) ... - -import { buildMetricKey } from '@/utils/metricsKeys'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { - DEGRADED_HEALTH_STATUSES, - ERROR_CONTAINER_STATES, - OFFLINE_HEALTH_STATUSES, - STOPPED_CONTAINER_STATES, - getDockerContainerStatusIndicator, - getDockerHostStatusIndicator, - getDockerServiceStatusIndicator, -} from '@/utils/status'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; -import { ResponsiveMetricCell } from '@/components/shared/responsive'; -import { useBreakpoint } from '@/hooks/useBreakpoint'; -import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; - -import { UpdateButton } from '@/components/Docker/UpdateBadge'; -import type { ColumnConfig } from '@/types/responsive'; -import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab'; -import { HistoryChart } from '@/components/shared/HistoryChart'; -import type { HistoryTimeRange } from '@/api/charts'; -import { GuestMetadataAPI, type GuestMetadata } from '@/api/guestMetadata'; -import { logger } from '@/utils/logger'; - -type GuestMetadataRecord = Record; - -// Module-level cache for guest metadata to persist across component remounts -let cachedDockerGuestMetadata: GuestMetadataRecord | null = null; - -const getDockerGuestMetadataCache = (): GuestMetadataRecord => { - return cachedDockerGuestMetadata ?? {}; -}; - -const setDockerGuestMetadataCache = (metadata: GuestMetadataRecord) => { - cachedDockerGuestMetadata = metadata; -}; - -const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'dockerHost' | 'unknown') => { - switch (type) { - case 'container': - return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200'; - case 'service': - return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-200'; - case 'task': - return 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-200'; - case 'dockerHost': - return 'bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-200'; - default: - return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; - } -}; - -type StatsFilter = - | { type: 'host-status'; value: string } - | { type: 'container-state'; value: string } - | { type: 'service-health'; value: string } - | null; - -type SearchToken = { key?: string; value: string }; - -type DockerRow = - | { - kind: 'container'; - id: string; - host: DockerHost; - container: DockerContainer; - } - | { - kind: 'service'; - id: string; - host: DockerHost; - service: DockerService; - tasks: DockerTask[]; - }; - - -export interface DockerUnifiedTableProps { - hosts: DockerHost[]; - searchTerm?: string; - statsFilter?: StatsFilter; - selectedHostId?: () => string | null; - batchUpdateState?: Record; - groupingMode?: 'grouped' | 'flat'; -} - -type SortKey = - | 'host' - | 'resource' - | 'type' - | 'image' - | 'status' - | 'cpu' - | 'memory' - | 'disk' - | 'tasks' - | 'updated'; - -type SortDirection = 'asc' | 'desc'; - -const SORT_KEYS: SortKey[] = [ - 'host', - 'resource', - 'type', - 'image', - 'status', - 'cpu', - 'memory', - 'disk', - 'tasks', - 'updated', -]; - -const SORT_DEFAULT_DIRECTION: Record = { - host: 'asc', - resource: 'asc', - type: 'asc', - image: 'asc', - status: 'desc', - cpu: 'desc', - memory: 'desc', - disk: 'desc', - tasks: 'desc', - updated: 'desc', -}; - -// Column configuration using the priority system (matching Proxmox overview pattern) -// Extends ColumnConfig for type compatibility with useGridTemplate -interface DockerColumnDef extends ColumnConfig { - shortLabel?: string; // Short label for narrow viewports -} - -// Column definitions with responsive priorities: -// - essential: Always visible (xs and up) -// - primary: Visible on small screens and up (sm: 640px+) -// - secondary: Visible on medium screens and up (md: 768px+) -// - supplementary: Visible on large screens and up (lg: 1024px+) -// - detailed: Visible on extra large screens and up (xl: 1280px+) -export const DOCKER_COLUMNS: DockerColumnDef[] = [ - { id: 'resource', label: 'Resource', priority: 'essential', minWidth: 'auto', flex: 1, sortKey: 'resource', width: '15%' }, - { id: 'type', label: 'Type', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'type', width: '70px' }, - { id: 'image', label: 'Image / Stack', priority: 'essential', minWidth: '80px', maxWidth: '200px', sortKey: 'image', width: '15%' }, - { id: 'status', label: 'Status', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'status', width: '90px' }, - // Metric columns - need fixed width to match progress bar max-width (140px + padding) - // Note: Disk column removed - Docker API rarely provides this data - { id: 'cpu', label: 'CPU', priority: 'essential', minWidth: '55px', maxWidth: '156px', sortKey: 'cpu', width: '140px' }, - { id: 'memory', label: 'Memory', priority: 'essential', minWidth: '75px', maxWidth: '156px', sortKey: 'memory', width: '140px' }, - { id: 'tasks', label: 'Tasks', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'tasks', width: '80px' }, - { id: 'updated', label: 'Uptime', priority: 'essential', minWidth: 'auto', maxWidth: 'auto', sortKey: 'updated', width: '80px' }, -]; - -// Global state for currently expanded drawer (only one drawer open at a time) -const [currentlyExpandedRowId, setCurrentlyExpandedRowId] = createSignal(null); - - - -const toLower = (value?: string | null) => value?.toLowerCase() ?? ''; - - - - - -const ensureMs = (value?: number | string | null): number | null => { - if (!value) return null; - if (typeof value === 'number') { - return value > 1e12 ? value : value * 1000; - } - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? null : parsed; -}; - -const parseSearchTerm = (term?: string): SearchToken[] => { - if (!term) return []; - return term - .trim() - .split(/\s+/) - .filter(Boolean) - .map((token) => { - const [rawKey, ...rest] = token.split(':'); - if (rest.length === 0) { - return { value: token.toLowerCase() }; - } - return { key: rawKey.toLowerCase(), value: rest.join(':').toLowerCase() }; - }); -}; - -const getHostDisplayName = (host: DockerHost): string => - host.customDisplayName || host.displayName || host.hostname || host.id || ''; - -const compareStrings = (a: string, b: string) => - a.localeCompare(b, undefined, { sensitivity: 'base' }); - -const STATUS_SEVERITY: Record = { - error: 3, - critical: 3, - danger: 3, - warning: 2, - degraded: 2, - offline: 2, - alert: 2, - info: 1, - success: 1, - ok: 1, - default: 0, -}; - -const getResourceName = (row: DockerRow) => - row.kind === 'container' - ? row.container.name || row.container.id || '' - : row.service.name || row.service.id || ''; - -const getImageKey = (row: DockerRow) => - row.kind === 'container' - ? row.container.image || '' - : row.service.image || row.service.stack || ''; - -const getTypeSortValue = (row: DockerRow) => (row.kind === 'container' ? 0 : 1); - -const getStatusSortValue = (row: DockerRow) => { - const indicator = - row.kind === 'container' - ? getDockerContainerStatusIndicator(row.container) - : getDockerServiceStatusIndicator(row.service); - return STATUS_SEVERITY[toLower(indicator.variant)] ?? 0; -}; - -const getContainerCpuSortValue = (container: DockerContainer) => { - const running = toLower(container.state) === 'running'; - const value = Number.isFinite(container.cpuPercent) ? container.cpuPercent : Number.NEGATIVE_INFINITY; - if (!running || value <= 0) return Number.NEGATIVE_INFINITY; - return value; -}; - -const getContainerMemorySortValue = (container: DockerContainer) => { - const running = toLower(container.state) === 'running'; - const value = Number.isFinite(container.memoryPercent) - ? container.memoryPercent - : Number.NEGATIVE_INFINITY; - if (!running || !container.memoryUsageBytes) return Number.NEGATIVE_INFINITY; - return value; -}; - -const getContainerDiskSortValue = (container: DockerContainer) => { - const total = container.rootFilesystemBytes ?? 0; - const used = container.writableLayerBytes ?? 0; - if (total <= 0 || used <= 0) return Number.NEGATIVE_INFINITY; - return Math.min(100, (used / total) * 100); -}; - -const getCpuSortValue = (row: DockerRow) => - row.kind === 'container' ? getContainerCpuSortValue(row.container) : Number.NEGATIVE_INFINITY; - -const getMemorySortValue = (row: DockerRow) => - row.kind === 'container' ? getContainerMemorySortValue(row.container) : Number.NEGATIVE_INFINITY; - -const getDiskSortValue = (row: DockerRow) => - row.kind === 'container' ? getContainerDiskSortValue(row.container) : Number.NEGATIVE_INFINITY; - -const getTasksSortValue = (row: DockerRow) => { - if (row.kind === 'container') { - const restarts = Number.isFinite(row.container.restartCount) - ? row.container.restartCount - : 0; - return -restarts; - } - - const desired = row.service.desiredTasks ?? 0; - const running = row.service.runningTasks ?? 0; - if (desired > 0) { - return running / desired; - } - if (running > 0) return 1; - return 0; -}; - -const getUpdatedSortValue = (row: DockerRow) => { - if (row.kind === 'container') { - const uptime = row.container.uptimeSeconds; - if (!Number.isFinite(uptime)) return Number.NEGATIVE_INFINITY; - return Date.now() - uptime * 1000; - } - const timestamp = ensureMs(row.service.updatedAt ?? row.service.createdAt); - return timestamp ?? Number.NEGATIVE_INFINITY; -}; - -const compareRowsByKey = (a: DockerRow, b: DockerRow, key: SortKey) => { - switch (key) { - case 'host': - return compareStrings(toLower(getHostDisplayName(a.host)), toLower(getHostDisplayName(b.host))); - case 'resource': - return compareStrings(toLower(getResourceName(a)), toLower(getResourceName(b))); - case 'type': - return getTypeSortValue(a) - getTypeSortValue(b); - case 'image': - return compareStrings(toLower(getImageKey(a)), toLower(getImageKey(b))); - case 'status': - return getStatusSortValue(a) - getStatusSortValue(b); - case 'cpu': - return getCpuSortValue(a) - getCpuSortValue(b); - case 'memory': - return getMemorySortValue(a) - getMemorySortValue(b); - case 'disk': - return getDiskSortValue(a) - getDiskSortValue(b); - case 'tasks': - return getTasksSortValue(a) - getTasksSortValue(b); - case 'updated': - return getUpdatedSortValue(a) - getUpdatedSortValue(b); - default: - return compareStrings(toLower(getResourceName(a)), toLower(getResourceName(b))); - } -}; - -interface PodmanMetadataItem { - label: string; - value?: string; -} - -interface PodmanMetadataSection { - title: string; - items: PodmanMetadataItem[]; -} - -const PODMAN_METADATA_GROUPS: Array<{ - title: string; - prefixes?: string[]; - keys?: string[]; -}> = [ - { - title: 'Pod', - prefixes: ['io.podman.annotations.pod.', 'io.podman.pod.', 'net.containers.podman.pod.'], - }, - { - title: 'Compose', - prefixes: ['io.podman.compose.'], - }, - { - title: 'Auto Update', - prefixes: ['io.containers.autoupdate.'], - keys: ['io.containers.autoupdate'], - }, - { - title: 'User Namespace', - keys: ['io.podman.annotations.userns', 'io.containers.userns'], - }, - { - title: 'Capabilities', - keys: ['io.containers.capabilities', 'io.containers.selinux', 'io.containers.seccomp'], - }, - { - title: 'Podman Annotations', - prefixes: ['io.podman.annotations.'], - }, - { - title: 'Container Settings', - prefixes: ['io.containers.'], - }, - ]; - -const humanizePodmanKey = (raw: string): string => { - if (!raw) return 'Value'; - const cleaned = raw.replace(/[_\-.]+/g, ' ').trim(); - if (!cleaned) return 'Value'; - return cleaned - .split(' ') - .map((segment) => { - if (!segment) return segment; - if (segment.toUpperCase() === segment) return segment; - return segment.charAt(0).toUpperCase() + segment.slice(1); - }) - .join(' ') - .replace(/\bId\b/g, 'ID') - .replace(/\bUrl\b/g, 'URL'); -}; - -const stripPrefix = (key: string, prefixes: string[] = []): string => { - for (const prefix of prefixes) { - if (prefix && key.startsWith(prefix)) { - const stripped = key.slice(prefix.length); - if (stripped) { - return stripped; - } - } - } - const lastDot = key.lastIndexOf('.'); - if (lastDot >= 0 && lastDot < key.length - 1) { - return key.slice(lastDot + 1); - } - return key; -}; - -const buildPodmanMetadataSections = ( - metadata?: DockerContainer['podman'], - labels?: Record, -): PodmanMetadataSection[] => { - const sections: PodmanMetadataSection[] = []; - const consumed = new Set(); - const markConsumed = (...keys: (string | undefined)[]) => { - keys.forEach((key) => { - if (key) consumed.add(key); - }); - }; - - const pushSection = (title: string, items: PodmanMetadataItem[]) => { - if (items.length > 0) { - sections.push({ title, items }); - } - }; - - if (metadata) { - const podItems: PodmanMetadataItem[] = []; - if (metadata.podName) { - podItems.push({ label: 'Pod Name', value: metadata.podName }); - markConsumed('io.podman.annotations.pod.name'); - } - if (metadata.podId) { - podItems.push({ label: 'Pod ID', value: metadata.podId }); - markConsumed('io.podman.annotations.pod.id'); - } - if (metadata.infra !== undefined) { - podItems.push({ label: 'Infra Container', value: metadata.infra ? 'true' : 'false' }); - markConsumed('io.podman.annotations.pod.infra'); - } - pushSection('Pod', podItems); - - const composeItems: PodmanMetadataItem[] = []; - if (metadata.composeProject) { - composeItems.push({ label: 'Project', value: metadata.composeProject }); - markConsumed('io.podman.compose.project'); - } - if (metadata.composeService) { - composeItems.push({ label: 'Service', value: metadata.composeService }); - markConsumed('io.podman.compose.service'); - } - if (metadata.composeWorkdir) { - composeItems.push({ label: 'Working Dir', value: metadata.composeWorkdir }); - markConsumed('io.podman.compose.working_dir'); - } - if (metadata.composeConfigHash) { - composeItems.push({ label: 'Config Hash', value: metadata.composeConfigHash }); - markConsumed('io.podman.compose.config-hash'); - } - pushSection('Compose', composeItems); - - const autoUpdateItems: PodmanMetadataItem[] = []; - if (metadata.autoUpdatePolicy) { - autoUpdateItems.push({ label: 'Policy', value: metadata.autoUpdatePolicy }); - markConsumed('io.containers.autoupdate'); - } - if (metadata.autoUpdateRestart) { - autoUpdateItems.push({ label: 'Restart', value: metadata.autoUpdateRestart }); - markConsumed('io.containers.autoupdate.restart'); - } - pushSection('Auto Update', autoUpdateItems); - - const namespaceItems: PodmanMetadataItem[] = []; - if (metadata.userNamespace) { - namespaceItems.push({ label: 'User Namespace', value: metadata.userNamespace }); - markConsumed('io.podman.annotations.userns', 'io.containers.userns'); - } - pushSection('Security', namespaceItems); - } - - if (!labels || Object.keys(labels).length === 0) { - return sections; - } - - const entries = Object.entries(labels); - const remaining = entries.filter( - ([key]) => - !consumed.has(key) && (key.includes('podman') || key.startsWith('io.containers.')), - ); - if (remaining.length === 0) { - return sections; - } - - const used = new Set(); - const addSection = (title: string, prefixes: string[] = [], keys: string[] = []) => { - const items: Array<[string, string]> = []; - - for (const [key, value] of remaining) { - if (used.has(key)) continue; - - const matchesPrefix = prefixes.some((prefix) => prefix && key.startsWith(prefix)); - const matchesKey = keys.includes(key); - - if (!matchesPrefix && !matchesKey) continue; - - items.push([key, value]); - used.add(key); - } - - if (items.length === 0) return; - - sections.push({ - title, - items: items.map(([key, value]) => ({ - label: humanizePodmanKey(stripPrefix(key, prefixes)), - value: value || undefined, - })), - }); - }; - - for (const group of PODMAN_METADATA_GROUPS) { - addSection(group.title, group.prefixes ?? [], group.keys ?? []); - } - - const leftovers = remaining.filter(([key]) => !used.has(key)); - if (leftovers.length > 0) { - sections.push({ - title: 'Additional Podman Labels', - items: leftovers.map(([key, value]) => ({ - label: humanizePodmanKey(stripPrefix(key)), - value: value || undefined, - })), - }); - } - - return sections; -}; - -const findContainerForTask = (containers: DockerContainer[], task: DockerTask) => { - if (!containers.length) return undefined; - - const taskId = task.containerId?.toLowerCase() ?? ''; - const taskName = task.containerName?.toLowerCase() ?? ''; - const taskNameBase = taskName.split('.')[0] || taskName; - - return containers.find((container) => { - const id = container.id?.toLowerCase() ?? ''; - const name = container.name?.toLowerCase() ?? ''; - - const idMatch = !!taskId && (id === taskId || id.includes(taskId) || taskId.includes(id)); - const nameMatch = - !!taskName && - (name === taskName || - name.includes(taskName) || - taskName.includes(name) || - (!!taskNameBase && (name === taskNameBase || name.includes(taskNameBase)))); - - return idMatch || nameMatch; - }); -}; - -const hostMatchesFilter = (filter: StatsFilter, host: DockerHost) => { - if (!filter || filter.type !== 'host-status') return true; - const status = toLower(host.status); - if (filter.value === 'offline') { - return OFFLINE_HEALTH_STATUSES.has(status); - } - if (filter.value === 'degraded') { - return DEGRADED_HEALTH_STATUSES.has(status); - } - if (filter.value === 'online') { - return status === 'online'; - } - return true; -}; - -const containerMatchesStateFilter = (filter: StatsFilter, container: DockerContainer) => { - if (!filter || filter.type !== 'container-state') return true; - const state = toLower(container.state); - if (filter.value === 'running') return state === 'running'; - if (filter.value === 'stopped') return STOPPED_CONTAINER_STATES.has(state); - if (filter.value === 'error') { - return ERROR_CONTAINER_STATES.has(state) || toLower(container.health) === 'unhealthy'; - } - return true; -}; - -const serviceMatchesHealthFilter = (filter: StatsFilter, service: DockerService) => { - if (!filter || filter.type !== 'service-health') return true; - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - if (filter.value === 'degraded') { - return desired > 0 && running < desired; - } - if (filter.value === 'healthy') { - return desired > 0 && running >= desired; - } - return true; -}; - -const containerMatchesToken = ( - token: SearchToken, - host: DockerHost, - container: DockerContainer, -) => { - const state = toLower(container.state); - const health = toLower(container.health); - const hostName = toLower(host.customDisplayName ?? host.displayName ?? host.hostname ?? host.id); - - if (token.key === 'name') { - return ( - toLower(container.name).includes(token.value) || - toLower(container.id).includes(token.value) - ); - } - - if (token.key === 'image') { - return toLower(container.image).includes(token.value); - } - - if (token.key === 'host') { - return hostName.includes(token.value); - } - - if (token.key === 'pod') { - const pod = container.podman?.podName?.toLowerCase() ?? ''; - return pod.includes(token.value); - } - - if (token.key === 'compose') { - const project = container.podman?.composeProject?.toLowerCase() ?? ''; - const service = container.podman?.composeService?.toLowerCase() ?? ''; - return project.includes(token.value) || service.includes(token.value); - } - - if (token.key === 'state') { - return state.includes(token.value) || health.includes(token.value); - } - - // Special filter for containers with updates available - if (token.key === 'has' && token.value === 'update') { - return container.updateStatus?.updateAvailable === true; - } - - const fields: string[] = [ - container.name, - container.id, - container.image, - container.status, - container.state, - container.health, - host.displayName, - host.hostname, - host.id, - ] - .filter(Boolean) - .map((value) => value!.toLowerCase()); - - if (container.podman) { - [ - container.podman.podName, - container.podman.podId, - container.podman.composeProject, - container.podman.composeService, - container.podman.autoUpdatePolicy, - container.podman.userNamespace, - ] - .filter(Boolean) - .forEach((value) => fields.push(value!.toLowerCase())); - } - - if (container.labels) { - Object.entries(container.labels).forEach(([key, value]) => { - fields.push(key.toLowerCase()); - if (value) fields.push(value.toLowerCase()); - }); - } - - if (container.ports) { - container.ports.forEach((port) => { - const parts = [port.privatePort, port.publicPort, port.protocol, port.ip] - .filter(Boolean) - .map(String) - .join(':') - .toLowerCase(); - if (parts) fields.push(parts); - }); - } - - return fields.some((field) => field.includes(token.value)); -}; - -const serviceMatchesToken = (token: SearchToken, host: DockerHost, service: DockerService) => { - const hostName = toLower(host.customDisplayName ?? host.displayName ?? host.hostname ?? host.id); - const serviceName = toLower(service.name ?? service.id); - const image = toLower(service.image); - - if (token.key === 'name') { - return serviceName.includes(token.value); - } - - if (token.key === 'image') { - return image.includes(token.value); - } - - if (token.key === 'host') { - return hostName.includes(token.value); - } - - if (token.key === 'state') { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - const status = desired > 0 && running >= desired ? 'healthy' : 'degraded'; - return status.includes(token.value); - } - - const fields: string[] = [ - service.name, - service.id, - service.image, - service.stack, - service.mode, - host.displayName, - host.hostname, - host.id, - ] - .filter(Boolean) - .map((value) => value!.toLowerCase()); - - if (service.labels) { - Object.entries(service.labels).forEach(([key, value]) => { - fields.push(key.toLowerCase()); - if (value) fields.push(value.toLowerCase()); - }); - } - - return fields.some((field) => field.includes(token.value)); -}; - -const serviceHealthBadge = (service: DockerService) => { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - if (desired === 0) { - return { - label: 'No tasks', - class: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300', - }; - } - if (running >= desired) { - return { - label: 'Healthy', - class: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', - }; - } - return { - label: `Degraded (${running}/${desired})`, - class: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', - }; -}; - -const buildRowId = (host: DockerHost, row: DockerRow) => { - if (row.kind === 'container') { - return `container:${host.id}:${row.container.id ?? row.container.name}`; - } - return `service:${host.id}:${row.service.id ?? row.service.name}`; -}; - -const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; -const UNGROUPED_RESOURCE_INDENT = 'pl-4 sm:pl-5 lg:pl-6'; - -const DockerHostGroupHeader: Component<{ - host: DockerHost; - columnCount: number; -}> = (props) => { - const displayName = getHostDisplayName(props.host); - const hostStatus = () => getDockerHostStatusIndicator(props.host); - const isOnline = () => hostStatus().variant === 'success'; - - return ( - - -
- - {displayName} - - ({props.host.hostname}) - -
- - - ); -}; - -const DockerContainerRow: Component<{ - row: Extract; - isMobile: Accessor; - showHostContext?: boolean; - resourceIndentClass?: string; - batchUpdateState?: Record; - guestMetadata?: GuestMetadataRecord; - onCustomUrlChange?: (guestId: string, url: string) => void; -}> = (props) => { - const { host, container } = props.row; - const runtimeInfo = resolveHostRuntime(host); - const runtimeVersion = () => host.runtimeVersion || host.dockerVersion || null; - const hostStatus = createMemo(() => getDockerHostStatusIndicator(host)); - const hostDisplayName = () => getHostDisplayName(host); - const rowId = buildRowId(host, props.row); - - const resourceIndent = () => props.resourceIndentClass ?? GROUPED_RESOURCE_INDENT; - - const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); - const [historyRange, setHistoryRange] = createSignal('1h'); - const expanded = createMemo(() => currentlyExpandedRowId() === rowId); - - - const batchState = createMemo(() => { - if (!props.batchUpdateState) return undefined; - const key = `${host.id}:${container.id}`; - return props.batchUpdateState[key]; - }); - - const writableLayerBytes = createMemo(() => container.writableLayerBytes ?? 0); - const rootFilesystemBytes = createMemo(() => container.rootFilesystemBytes ?? 0); - const hasDiskStats = createMemo(() => writableLayerBytes() > 0 || rootFilesystemBytes() > 0); - const diskPercent = createMemo(() => { - const total = rootFilesystemBytes(); - if (!total || total <= 0) return null; - const used = writableLayerBytes(); - if (used <= 0) return 0; - return Math.min(100, (used / total) * 100); - }); - const diskUsageLabel = createMemo(() => { - const used = writableLayerBytes(); - if (used <= 0) return '0 B'; - return formatBytes(used); - }); - const diskSublabel = createMemo(() => { - const total = rootFilesystemBytes(); - if (!total || total <= 0) return undefined; - return `${diskUsageLabel()} / ${formatBytes(total)}`; - }); - const createdRelative = createMemo(() => (container.createdAt ? formatRelativeTime(container.createdAt) : null)); - const createdAbsolute = createMemo(() => (container.createdAt ? formatAbsoluteTime(container.createdAt) : null)); - const startedRelative = createMemo(() => - container.startedAt ? formatRelativeTime(container.startedAt) : null, - ); - const startedAbsolute = createMemo(() => - container.startedAt ? formatAbsoluteTime(container.startedAt) : null, - ); - const mounts = createMemo(() => container.mounts || []); - const hasMounts = createMemo(() => mounts().length > 0); - const blockIo = createMemo(() => container.blockIo); - const blockIoReadBytes = createMemo(() => blockIo()?.readBytes ?? 0); - const blockIoWriteBytes = createMemo(() => blockIo()?.writeBytes ?? 0); - const blockIoReadRate = createMemo(() => blockIo()?.readRateBytesPerSecond ?? null); - const blockIoWriteRate = createMemo(() => blockIo()?.writeRateBytesPerSecond ?? null); - const formatIoRate = (value?: number | null) => { - if (value === undefined || value === null) return undefined; - if (value <= 0) return undefined; - const decimals = value >= 1024 * 1024 ? 1 : value >= 1024 ? 1 : 0; - return `${formatBytes(value, decimals)}/s`; - }; - const blockIoReadRateLabel = createMemo(() => formatIoRate(blockIoReadRate())); - const blockIoWriteRateLabel = createMemo(() => formatIoRate(blockIoWriteRate())); - const podmanMetadata = createMemo(() => container.podman); - const podName = createMemo(() => podmanMetadata()?.podName?.trim() || undefined); - const isPodInfra = createMemo(() => podmanMetadata()?.infra ?? false); - const podmanMetadataSections = createMemo(() => - buildPodmanMetadataSections(podmanMetadata(), container.labels), - ); - const hasPodmanMetadata = createMemo( - () => !!podmanMetadata() || podmanMetadataSections().length > 0, - ); - const hasBlockIo = createMemo(() => { - const stats = blockIo(); - if (!stats) return false; - const read = stats.readBytes ?? 0; - const write = stats.writeBytes ?? 0; - const readRate = stats.readRateBytesPerSecond ?? 0; - const writeRate = stats.writeRateBytesPerSecond ?? 0; - return read > 0 || write > 0 || readRate > 0 || writeRate > 0; - }); - const hasDrawerContent = createMemo(() => { - return ( - (container.ports && container.ports.length > 0) || - (container.labels && Object.keys(container.labels).length > 0) || - (container.networks && container.networks.length > 0) || - hasMounts() || - hasBlockIo() || - hasPodmanMetadata() - ); - }); - - - - // Auto-focus the input when editing starts - - - const toggle = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.closest('a, button, input, [data-prevent-toggle]')) return; - - // Standard drawer toggle - if (!hasDrawerContent()) return; - setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId); - }; - - - - // Add global click handler to close editor - - - - - const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0)); - const metricsKey = buildMetricKey('dockerContainer', container.id); - - const uptime = () => (container.uptimeSeconds ? formatUptime(container.uptimeSeconds) : '—'); - const restarts = () => container.restartCount ?? 0; - - const state = () => toLower(container.state); - const health = () => toLower(container.health); - const isRunning = () => state() === 'running'; - - const statusBadgeClass = () => { - if (state() === 'running' && (!health() || health() === 'healthy')) { - return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; - } - if (ERROR_CONTAINER_STATES.has(state()) || health() === 'unhealthy') { - return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; - } - if (STOPPED_CONTAINER_STATES.has(state())) { - return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; - } - return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; - }; - const containerStatusIndicator = createMemo(() => getDockerContainerStatusIndicator(container)); - - const statusLabel = () => { - if (health()) { - return `${container.state ?? 'Unknown'} (${container.health})`; - } - return container.status || container.state || 'Unknown'; - }; - - const containerTitle = () => { - const primary = container.name || container.id || 'Container'; - const identifier = container.id && container.name && container.id !== container.name ? container.id : ''; - return identifier ? `${primary} \u2014 ${identifier}` : primary; - }; - - // Render cell content based on column type - const renderCell = (column: ColumnConfig) => { - switch (column.id) { - case 'resource': - return ( -
-
-
- - - -
- -
-
- - {container.name || container.id} - - - {(name) => ( - - - - - - )} - - - - - - {hostDisplayName()} - - -
-
-
-
- ); - case 'type': - return ( -
- - {runtimeInfo.label} - -
- ); - case 'image': - return ( -
-
- - {getShortImageName(container.image)} - - -
-
- ); - case 'status': - return ( -
- {statusLabel()} -
- ); - case 'cpu': - return ( -
- 0} - showMobile={false} - class="w-full" - /> -
- ); - case 'memory': - const memoryTotal = () => container.memoryLimitBytes && container.memoryLimitBytes > 0 - ? container.memoryLimitBytes - : host.totalMemoryBytes; - - return ( -
-
- -
-
- ); - case 'disk': - return ( -
- —}> - {diskUsageLabel()}}> - - - -
- ); - case 'tasks': - return ( -
- —}> - 5 ? 'text-red-600 dark:text-red-400 font-medium' : ''}> - {restarts()} - - restarts - -
- ); - case 'updated': - return ( -
- —}> - - {formatUptime(container.uptimeSeconds || 0, true)} - - -
- ); - default: - return null; - } - }; - - return ( - <> - - - {(column) => ( - - {renderCell(column)} - - )} - - - - {/* URL editing popover - using shared component */} - - - - - -
-
- - -
- -
-
-
-
- Summary -
-
-
- Runtime - - {runtimeInfo.label} - - {(version) => ( - {version()} - )} - - -
-
- Image - - {container.image || '—'} - -
- - {(name) => ( -
- Pod - - {name()} - - - infra - - - -
- )} -
- - {(project) => ( -
- Compose Project - {project()} -
- )} -
- - {(service) => ( -
- Compose Service - {service()} -
- )} -
- - {(policy) => ( -
- Auto Update - - {policy()} - - {(restart) => ( - restart: {restart()} - )} - - -
- )} -
- - {(userns) => ( -
- User Namespace - {userns()} -
- )} -
-
- State - {statusLabel()} -
-
- Restarts - {restarts()} -
- - {(created) => ( -
- Created -
- {created()} - - {(abs) => ( -
{abs()}
- )} -
-
-
- )} -
- - {(started) => ( -
- Started -
- {started()} - - {(abs) => ( -
{abs()}
- )} -
-
-
- )} -
-
- Uptime - {uptime()} -
-
- -
- Podman hosts report container metrics, but Swarm services and tasks are unavailable. Runtime annotations and compose metadata appear below when present. -
-
-
- 0}> -
-
- Ports -
-
- {container.ports!.map((port) => { - const label = port.publicPort - ? `${port.publicPort}:${port.privatePort}/${port.protocol}` - : `${port.privatePort}/${port.protocol}`; - return ( - - {label} - - ); - })} -
-
-
- - 0}> -
-
- Networks -
-
- {container.networks!.map((network) => ( -
-
{network.name}
-
- - - {network.ipv4} - - - - - {network.ipv6} - - -
-
- ))} -
-
-
- - -
-
- Podman Metadata -
-
- - {(section) => ( -
-
- {section.title} -
-
- - {(item) => ( -
- {item.label} - - {item.value || '—'} - -
- )} -
-
-
- )} -
-
-
-
- - -
-
- Block I/O -
-
-
- Read -
-
- {formatBytes(blockIoReadBytes())} -
- -
- {blockIoReadRateLabel()} -
-
-
-
-
- Write -
-
- {formatBytes(blockIoWriteBytes())} -
- -
- {blockIoWriteRateLabel()} -
-
-
-
-
-
-
- - -
-
- Mounts -
-
- - {(mount) => { - const destination = mount.destination || mount.source || mount.name || 'mount'; - const rw = mount.rw === false ? 'read-only' : 'read-write'; - return ( -
-
- - {destination} - - - - {mount.type} - - -
- -
- {mount.source} -
-
-
- - {rw} - - - - mode: {mount.mode} - - - - - {mount.driver} - - - - - {mount.name} - - - - - {mount.propagation} - - -
-
- ); - }} -
-
-
-
- - 0}> -
-
- Labels -
-
- {Object.entries(container.labels!).map(([key, value]) => { - const fullLabel = value ? `${key}: ${value}` : key; - return ( - - {key} - : {value} - - ); - })} -
-
-
- - 0}> -
-
- Environment -
-
- {container.env!.map((envVar) => { - const eqIndex = envVar.indexOf('='); - if (eqIndex === -1) return null; - const name = envVar.substring(0, eqIndex); - const value = envVar.substring(eqIndex + 1); - const isMasked = value === '***'; - return ( - - {name} - - ={value} - - - 🔒 - - - ); - })} -
-
-
-
- -
-
- - - - - -
- -
-
-
-
- -
-
- -
-
-
-
-
-
- - - props.onCustomUrlChange?.(container.id, url)} - /> - -
- - -
- - ); -}; - -const DockerServiceRow: Component<{ - row: Extract; - isMobile: Accessor; - showHostContext?: boolean; - resourceIndentClass?: string; - guestMetadata?: GuestMetadataRecord; - onCustomUrlChange?: (guestId: string, url: string) => void; -}> = (props) => { - const { host, service, tasks } = props.row; - const rowId = buildRowId(host, props.row); - const resourceId = () => `${host.id}:service:${service.id || service.name}`; - const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); - const [historyRange, setHistoryRange] = createSignal('1h'); - const hostStatus = createMemo(() => getDockerHostStatusIndicator(host)); - const hostDisplayName = () => getHostDisplayName(host); - const resourceIndent = () => props.resourceIndentClass ?? GROUPED_RESOURCE_INDENT; - - const expanded = createMemo(() => currentlyExpandedRowId() === rowId); - - - const hasTasks = () => tasks.length > 0; - - - - - - - - const toggle = (event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.closest('a, button, input, [data-prevent-toggle]')) return; - - // Standard drawer toggle - setCurrentlyExpandedRowId(prev => prev === rowId ? null : rowId); - }; - - - - const badge = serviceHealthBadge(service); - const updatedAt = ensureMs(service.updatedAt ?? service.createdAt); - const isHealthy = () => { - const desired = service.desiredTasks ?? 0; - const running = service.runningTasks ?? 0; - return desired > 0 && running >= desired; - }; - const serviceStatusIndicator = createMemo(() => getDockerServiceStatusIndicator(service)); - - const serviceTitle = () => { - const primary = service.name || service.id || 'Service'; - const identifier = service.id && service.name && service.id !== service.name ? service.id : ''; - return identifier ? `${primary} \u2014 ${identifier}` : primary; - }; - - // Render cell content based on column type - const renderCell = (column: ColumnConfig) => { - switch (column.id) { - case 'resource': - return ( -
-
-
- - - -
- -
-
- - {service.name || service.id || 'Service'} - - - - - Stack: {service.stack} - - - - - - {hostDisplayName()} - - - -
-
-
-
- ); - case 'type': - return ( -
- - Service - -
- ); - case 'image': - return ( -
-
- - {getShortImageName(service.image)} - -
-
- ); - case 'status': - return ( -
- {badge.label} -
- ); - case 'cpu': - return
; - case 'memory': - return
; - case 'disk': - return
; - case 'tasks': - return ( -
- - {(service.runningTasks ?? 0)}/{service.desiredTasks ?? 0} - - tasks -
- ); - case 'updated': - return ( -
- - {(timestamp) => ( - - {formatRelativeTime(timestamp())} - - )} - -
- ); - default: - return null; - } - }; - - return ( - <> - - - {(column) => ( - - {renderCell(column)} - - )} - - - - - - -
-
- - -
- -
- No tasks running.
}> -
-
- Tasks - - {tasks.length} {tasks.length === 1 ? 'entry' : 'entries'} - -
-
- - - - - - - - - - - - - - - {(task) => { - const container = findContainerForTask(host.containers || [], task); - const cpu = container?.cpuPercent ?? 0; - const mem = container?.memoryPercent ?? 0; - const updated = ensureMs(task.updatedAt ?? task.createdAt ?? task.startedAt); - const taskLabel = () => { - if (task.containerName) return task.containerName; - if (task.containerId) return task.containerId.slice(0, 12); - if (task.slot !== undefined) return `slot-${task.slot}`; - return task.id ?? 'Task'; - }; - const taskTitle = () => { - const label = taskLabel(); - if (task.containerId && task.containerId !== label) { - return `${label} \u2014 ${task.containerId}`; - } - if (task.id && task.id !== label) { - return `${label} \u2014 ${task.id}`; - } - return label; - }; - const state = toLower(task.currentState ?? task.desiredState ?? 'unknown'); - const taskMetricsKey = container?.id ? buildMetricKey('dockerContainer', container.id) : undefined; - const stateClass = () => { - if (state === 'running') { - return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'; - } - if (state === 'failed' || state === 'error') { - return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'; - } - return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'; - }; - return ( - - - - - - - - - - ); - }} - - -
TaskTypeNodeStateCPUMemoryUpdated
-
- - {taskLabel()} - -
-
- - Task - - - {task.nodeName || task.nodeId || '—'} - - - {task.currentState || task.desiredState || 'Unknown'} - - - 0} fallback={}> - - - - 0} fallback={}> - - - - - {(timestamp) => ( - - {formatRelativeTime(timestamp())} - - )} - -
-
-
- -
- -
-
- - - - - -
- -
-
-
-
- -
-
- -
-
-
-
-
- - - props.onCustomUrlChange?.(resourceId(), url)} - /> - -
- - -
- - ); -}; - -const areTasksEqual = (a: DockerTask[], b: DockerTask[]) => { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -}; - -const DockerUnifiedTable: Component = (props) => { - // Use the breakpoint hook for responsive behavior - const { isMobile } = useBreakpoint(); - - // Guest metadata for tracking custom URLs - const [guestMetadata, setGuestMetadata] = createSignal(getDockerGuestMetadataCache()); - - // Load guest metadata on mount - onMount(async () => { - try { - const metadata = await GuestMetadataAPI.getAllMetadata(); - setGuestMetadata(metadata ?? {}); - setDockerGuestMetadataCache(metadata ?? {}); - } catch (err) { - logger.debug('Failed to load guest metadata for Docker', err); - } - - // Listen for metadata changes from other sources (e.g., AI, other tabs) - const handleMetadataChanged = async () => { - try { - const metadata = await GuestMetadataAPI.getAllMetadata(); - setGuestMetadata(metadata ?? {}); - setDockerGuestMetadataCache(metadata ?? {}); - } catch (err) { - logger.debug('Failed to refresh guest metadata', err); - } - }; - - window.addEventListener('pulse:metadata-changed', handleMetadataChanged); - return () => window.removeEventListener('pulse:metadata-changed', handleMetadataChanged); - }); - - // Handle custom URL changes from discovery tab - const handleCustomUrlChange = (guestId: string, url: string) => { - const trimmed = url.trim(); - setGuestMetadata((prev) => { - const updated = { ...prev }; - if (trimmed) { - updated[guestId] = { ...updated[guestId], id: guestId, customUrl: trimmed }; - } else if (updated[guestId]) { - const { customUrl: _, ...rest } = updated[guestId]; - if (Object.keys(rest).length > 1) { - updated[guestId] = rest as GuestMetadata; - } else { - delete updated[guestId]; - } - } - setDockerGuestMetadataCache(updated); - return updated; - }); - }; - - // Caches for stable object references to prevent re-animations - const rowCache = new Map(); - const tasksCache = new Map(); - - - const tokens = createMemo(() => parseSearchTerm(props.searchTerm)); - const [sortKey, setSortKey] = usePersistentSignal('dockerUnifiedSortKey', 'host', { - deserialize: (value) => (SORT_KEYS.includes(value as SortKey) ? (value as SortKey) : 'host'), - }); - const [sortDirection, setSortDirection] = usePersistentSignal( - 'dockerUnifiedSortDirection', - 'asc', - { - deserialize: (value) => (value === 'asc' || value === 'desc' ? value : 'asc'), - }, - ); - - const isGroupedView = createMemo(() => sortKey() === 'host'); - - // Sync external groupingMode prop with internal sort state - createEffect(() => { - const mode = props.groupingMode; - if (mode === 'grouped' && sortKey() !== 'host') { - setSortKey('host'); - } else if (mode === 'flat' && sortKey() === 'host') { - // Switch to resource sort for flat view - setSortKey('resource'); - } - }); - - const handleSort = (key: SortKey) => { - if (sortKey() === key) { - setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); - return; - } - setSortKey(key); - setSortDirection(SORT_DEFAULT_DIRECTION[key]); - }; - - const renderSortIndicator = (key: SortKey) => { - if (sortKey() !== key) return null; - return sortDirection() === 'asc' ? '▲' : '▼'; - }; - - const resetHostGrouping = () => { - setSortKey('host'); - setSortDirection(SORT_DEFAULT_DIRECTION.host); - }; - - const ariaSort = (key: SortKey) => { - if (sortKey() !== key) { - if (sortKey() === 'host' && key === 'resource') return 'other'; - return 'none'; - } - return sortDirection() === 'asc' ? 'ascending' : 'descending'; - }; - - const sortedHosts = createMemo(() => { - const hosts = props.hosts || []; - return [...hosts].sort((a, b) => { - const aName = getHostDisplayName(a); - const bName = getHostDisplayName(b); - return aName.localeCompare(bName); - }); - }); - - const groupedRows = createMemo(() => { - const groups: Array<{ host: DockerHost; rows: DockerRow[] }> = []; - const filter = props.statsFilter ?? null; - const searchTokens = tokens(); - const selectedHostId = props.selectedHostId ? props.selectedHostId() : null; - const usedCacheKeys = new Set(); - const usedTaskCacheKeys = new Set(); - - sortedHosts().forEach((host) => { - if (!hostMatchesFilter(filter, host)) { - return; - } - - if (selectedHostId && host.id !== selectedHostId) { - return; - } - - const containerRows: Array> = []; - const serviceRows: Array> = []; - - const containers = host.containers || []; - const services = host.services || []; - const tasks = host.tasks || []; - - const serviceNames = new Set(); - const serviceIds = new Set(); - services.forEach((service) => { - if (service.name) serviceNames.add(service.name.toLowerCase()); - if (service.id) serviceIds.add(service.id.toLowerCase()); - }); - - const serviceOwnedContainers = new Set(); - - containers.forEach((container) => { - if (!containerMatchesStateFilter(filter, container)) return; - const matchesSearch = searchTokens.every((token) => containerMatchesToken(token, host, container)); - if (!matchesSearch) return; - - const rowId = container.id || `${host.id}-container-${container.name}`; - const cacheKey = `c:${host.id}:${rowId}`; - usedCacheKeys.add(cacheKey); - - let row = rowCache.get(cacheKey); - if (!row || row.kind !== 'container' || row.host !== host || row.container !== container) { - row = { - kind: 'container', - id: rowId, - host, - container, - }; - rowCache.set(cacheKey, row); - } - - containerRows.push(row as Extract); - }); - - services.forEach((service) => { - if (!serviceMatchesHealthFilter(filter, service)) return; - const matchesSearch = searchTokens.every((token) => serviceMatchesToken(token, host, service)); - if (!matchesSearch) return; - - let associatedTasks = tasks.filter((task) => { - if (service.id && task.serviceId) { - return task.serviceId === service.id; - } - if (service.name && task.serviceName) { - return task.serviceName === service.name; - } - return false; - }); - - // Use stable array reference for tasks if content matches - const taskCacheKey = `s:${host.id}:${service.id || service.name}`; - usedTaskCacheKeys.add(taskCacheKey); - const cachedTasks = tasksCache.get(taskCacheKey); - if (cachedTasks && areTasksEqual(cachedTasks, associatedTasks)) { - associatedTasks = cachedTasks; - } else { - tasksCache.set(taskCacheKey, associatedTasks); - } - - associatedTasks.forEach((task) => { - if (task.containerId) serviceOwnedContainers.add(task.containerId.toLowerCase()); - if (task.containerName) serviceOwnedContainers.add(task.containerName.toLowerCase()); - }); - - const rowId = service.id || `${host.id}-service-${service.name}`; - const cacheKey = `s:${host.id}:${rowId}`; - usedCacheKeys.add(cacheKey); - - let row = rowCache.get(cacheKey); - // Check if row needs update (host/service changed, or tasks array changed) - if (!row || row.kind !== 'service' || row.host !== host || row.service !== service || row.tasks !== associatedTasks) { - row = { - kind: 'service', - id: rowId, - host, - service, - tasks: associatedTasks, - }; - rowCache.set(cacheKey, row); - } - - serviceRows.push(row as Extract); - }); - - if (serviceRows.length > 0) { - serviceRows.sort((a, b) => { - const nameA = a.service.name || a.service.id || ''; - const nameB = b.service.name || b.service.id || ''; - return nameA.localeCompare(nameB); - }); - } - - if (containerRows.length > 0) { - containerRows.sort((a, b) => { - const nameA = a.container.name || a.container.id || ''; - const nameB = b.container.name || b.container.id || ''; - return nameA.localeCompare(nameB); - }); - const filtered = containerRows.filter((row) => { - const idKey = (row.container.id || '').toLowerCase(); - const nameKey = (row.container.name || '').toLowerCase(); - const shortNameKey = nameKey.split('.')[0]; - - const labelServiceName = - row.container.labels?.['com.docker.swarm.service.name']?.toLowerCase() ?? ''; - const labelServiceID = - row.container.labels?.['com.docker.swarm.service.id']?.toLowerCase() ?? ''; - - const belongsToService = - (idKey && serviceOwnedContainers.has(idKey)) || - (nameKey && serviceOwnedContainers.has(nameKey)) || - (shortNameKey && serviceOwnedContainers.has(shortNameKey)) || - (labelServiceName && serviceNames.has(labelServiceName)) || - (labelServiceID && serviceIds.has(labelServiceID)) || - (serviceNames.size > 0 && nameKey && [...serviceNames].some((svc) => nameKey.startsWith(`${svc}.`))); - - return !belongsToService; - }); - - containerRows.length = 0; - containerRows.push(...filtered); - } - - const hostRows = [...serviceRows, ...containerRows]; - - if (hostRows.length > 0) { - groups.push({ host, rows: hostRows }); - } - }); - - // Prune caches - for (const key of rowCache.keys()) { - if (!usedCacheKeys.has(key)) { - rowCache.delete(key); - } - } - for (const key of tasksCache.keys()) { - if (!usedTaskCacheKeys.has(key)) { - tasksCache.delete(key); - } - } - - return groups; - }); - - const flatRows = createMemo(() => groupedRows().flatMap((group) => group.rows)); - - const orderedGroups = createMemo(() => { - if (sortKey() !== 'host') { - return groupedRows(); - } - if (sortDirection() === 'asc') return groupedRows(); - const reversed = [...groupedRows()]; - reversed.reverse(); - return reversed; - }); - - const sortedRows = createMemo(() => { - if (sortKey() === 'host') { - return flatRows(); - } - - const rows = [...flatRows()]; - const key = sortKey(); - const dir = sortDirection(); - - rows.sort((a, b) => { - const primary = compareRowsByKey(a, b, key); - if (primary !== 0) { - return dir === 'asc' ? primary : -primary; - } - - const byResource = compareRowsByKey(a, b, 'resource'); - if (byResource !== 0) { - return byResource; - } - - return compareRowsByKey(a, b, 'host'); - }); - - return rows; - }); - - const totalRows = createMemo(() => flatRows().length); - - const totalContainers = createMemo(() => - (props.hosts || []).reduce((acc, host) => acc + (host.containers?.length ?? 0), 0), - ); - const totalServices = createMemo(() => - (props.hosts || []).reduce((acc, host) => acc + (host.services?.length ?? 0), 0), - ); - - const runningContainers = createMemo(() => - groupedRows().reduce((acc, group) => { - return ( - acc + - group.rows - .filter((row): row is Extract => row.kind === 'container') - .filter((row) => toLower(row.container.state) === 'running').length - ); - }, 0), - ); - - const stoppedContainers = createMemo(() => - groupedRows().reduce((acc, group) => { - return ( - acc + - group.rows - .filter((row): row is Extract => row.kind === 'container') - .filter((row) => STOPPED_CONTAINER_STATES.has(toLower(row.container.state))).length - ); - }, 0), - ); - - const degradedContainers = createMemo(() => - groupedRows().reduce((acc, group) => { - return ( - acc + - group.rows - .filter((row): row is Extract => row.kind === 'container') - .filter((row) => { - const state = toLower(row.container.state); - const health = toLower(row.container.health); - - // Explicitly degraded/error states - if (ERROR_CONTAINER_STATES.has(state)) return true; - - // Running but unhealthy - if (state === 'running' && health === 'unhealthy') return true; - - // Any other state that is NOT running and NOT stopped - if (state !== 'running' && !STOPPED_CONTAINER_STATES.has(state)) return true; - - return false; - }).length - ); - }, 0), - ); - - const renderRow = (row: DockerRow, grouped: boolean) => { - - - return row.kind === 'container' ? ( - - - ) : ( - - ); - }; - - return ( -
- 0} - fallback={ - - - - } - > - -
- - - - - {(column) => { - const col = column as DockerColumnDef; - const colSortKey = col.sortKey as SortKey | undefined; - const isResource = col.id === 'resource'; - return ( - - ); - }} - - - - - - {(row) => renderRow(row, false)} - - } - > - - {(group) => ( - <> - - - {(row) => renderRow(row, true)} - - - )} - - - -
colSortKey && handleSort(colSortKey)} - onKeyDown={(e) => e.key === 'Enter' && colSortKey && handleSort(colSortKey)} - tabIndex={0} - role="button" - aria-label={`Sort by ${col.label} ${colSortKey && sortKey() === colSortKey ? (sortDirection() === 'asc' ? 'ascending' : 'descending') : ''}`} - aria-sort={colSortKey ? ariaSort(colSortKey) : 'none'} - > - -
- {col.label} - {colSortKey && renderSortIndicator(colSortKey)} - - Grouped by host - - - - -
-
- -
- {col.label} - {colSortKey && renderSortIndicator(colSortKey)} -
-
-
-
-
- -
- - - 0}> - | - - - - | - - -
-
-
- ); -}; - -export { DockerUnifiedTable }; diff --git a/frontend-modern/src/components/Docker/UpdateBadge.tsx b/frontend-modern/src/components/Docker/UpdateBadge.tsx deleted file mode 100644 index 4c033be92..000000000 --- a/frontend-modern/src/components/Docker/UpdateBadge.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { Component, Show, createSignal, createEffect, createMemo } from 'solid-js'; -import type { DockerContainerUpdateStatus } from '@/types/api'; -import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; -import { MonitoringAPI } from '@/api/monitoring'; -import { - getContainerUpdateState, - markContainerQueued, - markContainerUpdateSuccess, - markContainerUpdateError, - clearContainerUpdateState, - updateStates -} from '@/stores/containerUpdates'; -import { shouldHideDockerUpdateActions, areSystemSettingsLoaded } from '@/stores/systemSettings'; - - - -interface UpdateBadgeProps { - updateStatus?: DockerContainerUpdateStatus; - compact?: boolean; -} - -/** - * UpdateBadge displays a visual indicator when a container image has an update available. - * Uses a blue color scheme to differentiate from health/status badges. - */ -export const UpdateBadge: Component = (props) => { - const hasUpdate = () => props.updateStatus?.updateAvailable === true; - const hasError = () => Boolean(props.updateStatus?.error); - - return ( - - - { - const rect = e.currentTarget.getBoundingClientRect(); - showTooltip(`Update check failed: ${props.updateStatus?.error}`, rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up' - }); - }} - onMouseLeave={() => hideTooltip()} - > - - - - - Check failed - - - - } - > - { - const rect = e.currentTarget.getBoundingClientRect(); - const current = props.updateStatus?.currentDigest?.slice(0, 19) || 'unknown'; - const latest = props.updateStatus?.latestDigest?.slice(0, 19) || 'unknown'; - const content = `Image update available\nCurrent: ${current}...\nLatest: ${latest}...`; - showTooltip(content, rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up' - }); - }} - onMouseLeave={() => hideTooltip()} - > - - - - - Update - - - - - ); -}; - -/** - * Compact version of UpdateBadge - just an icon with no text. - * Use this in table cells where space is limited. - */ -export const UpdateIcon: Component<{ updateStatus?: DockerContainerUpdateStatus }> = (props) => { - const hasUpdate = () => props.updateStatus?.updateAvailable === true; - - const getTooltip = () => { - if (!props.updateStatus) return 'Image update available'; - - const current = props.updateStatus.currentDigest?.slice(0, 12) || 'unknown'; - const latest = props.updateStatus.latestDigest?.slice(0, 12) || 'unknown'; - - return `Update available\nCurrent: ${current}...\nLatest: ${latest}...`; - }; - - return ( - - { - const rect = e.currentTarget.getBoundingClientRect(); - showTooltip(getTooltip(), rect.left + rect.width / 2, rect.top, { - align: 'center', - direction: 'up' - }); - }} - onMouseLeave={() => hideTooltip()} - > - - - - - - ); -}; - -interface UpdateButtonProps { - updateStatus?: DockerContainerUpdateStatus; - hostId: string; - containerId: string; - containerName: string; - compact?: boolean; - onUpdateTriggered?: () => void; - externalState?: 'updating' | 'queued' | 'error'; -} - - -type UpdateState = 'idle' | 'confirming' | 'updating' | 'success' | 'error'; - -/** - * UpdateButton displays a clickable button to trigger container updates. - * Uses a persistent store to maintain state across WebSocket refreshes. - * - * If the server has disabled Docker update actions (via PULSE_DISABLE_DOCKER_UPDATE_ACTIONS - * or the Settings UI), this component will render a read-only UpdateBadge instead, - * allowing users to see that updates are available without being able to trigger them. - * - * While system settings are loading, the button displays in a disabled/loading state - * to prevent premature clicks before the server configuration is known. - */ -export const UpdateButton: Component = (props) => { - const [localState, setLocalState] = createSignal<'idle' | 'confirming'>('idle'); - const [errorMessage, setErrorMessage] = createSignal(''); - - // Reactive check for whether settings are loaded and what they say - const settingsLoaded = () => areSystemSettingsLoaded(); - const shouldHideButton = () => shouldHideDockerUpdateActions(); - - // Get persistent state from store - this survives WebSocket updates - const storeState = createMemo(() => { - // Access updateStates() to create reactive dependency - updateStates(); - return getContainerUpdateState(props.hostId, props.containerId); - }); - - // Derived state: check store first, then external prop, then local state - const currentState = (): UpdateState => { - const stored = storeState(); - if (stored) { - switch (stored.state) { - case 'queued': - case 'updating': - return 'updating'; - case 'success': - return 'success'; - case 'error': - return 'error'; - } - } - if (props.externalState === 'updating') return 'updating'; - if (props.externalState === 'queued') return 'updating'; - if (props.externalState === 'error') return 'error'; - return localState(); - }; - - // Watch for update completion - when updateAvailable becomes false, the update succeeded - createEffect(() => { - const stored = storeState(); - if (stored && (stored.state === 'queued' || stored.state === 'updating')) { - // If the container no longer has an update available, the update succeeded! - if (props.updateStatus?.updateAvailable === false) { - markContainerUpdateSuccess(props.hostId, props.containerId); - } - } - }); - - const hasUpdate = () => props.updateStatus?.updateAvailable === true || currentState() !== 'idle'; - - const handleClick = async (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - - const state = currentState(); - - // Prevent clicking if already updating - if (state === 'updating' || state === 'success' || state === 'error') return; - - if (state === 'idle') { - // Show confirmation - setLocalState('confirming'); - return; - } - - if (state === 'confirming') { - // User confirmed, trigger update - // Immediately set store state so it persists - markContainerQueued(props.hostId, props.containerId); - setLocalState('idle'); // Reset local state - - try { - await MonitoringAPI.updateDockerContainer( - props.hostId, - props.containerId, - props.containerName - ); - // Command queued successfully - store already has 'queued' state - // The effect above will detect when updateAvailable becomes false - props.onUpdateTriggered?.(); - } catch (err) { - const message = (err as Error).message || 'Failed to trigger update'; - setErrorMessage(message); - markContainerUpdateError(props.hostId, props.containerId, message); - } - } - }; - - const handleCancel = (e: MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - setLocalState('idle'); - // Also clear any store state if canceling - clearContainerUpdateState(props.hostId, props.containerId); - }; - - const getButtonClass = () => { - const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-all'; - switch (currentState()) { - case 'confirming': - return `${base} bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900/60`; - case 'updating': - return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-wait`; - case 'success': - return `${base} bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300`; - case 'error': - return `${base} bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 cursor-help`; - default: - return `${base} bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60`; - } - }; - - const getTooltip = () => { - const stored = storeState(); - switch (currentState()) { - case 'confirming': - return 'Click again to confirm update'; - case 'updating': { - const elapsed = stored ? Math.round((Date.now() - stored.startedAt) / 1000) : 0; - // Show the current step if available from backend - const step = stored?.message || 'Processing...'; - if (elapsed > 60) { - return `${step} (${Math.floor(elapsed / 60)}m ${elapsed % 60}s)`; - } - return `${step} (${elapsed}s)`; - } - case 'success': - return '✓ Update completed successfully!'; - case 'error': - return `✗ Update failed: ${stored?.message || errorMessage() || 'Unknown error'}`; - default: - if (!props.updateStatus) return 'Update container'; - const current = props.updateStatus.currentDigest?.slice(0, 12) || 'unknown'; - const latest = props.updateStatus.latestDigest?.slice(0, 12) || 'unknown'; - return `Click to update\nCurrent: ${current}...\nLatest: ${latest}...`; - } - }; - - // Compute if the button should be disabled due to loading or settings - const isButtonDisabled = () => currentState() === 'updating' || !settingsLoaded(); - - return ( - - {/* Case 1: Settings loaded and updates are disabled - show read-only badge */} - - - - - {/* Case 2: Settings loading OR settings loaded with updates enabled - show button */} - -
- - - - -
-
-
- ); -}; - -export default UpdateBadge; - diff --git a/frontend-modern/src/components/Docker/runtimeDisplay.ts b/frontend-modern/src/components/Docker/runtimeDisplay.ts deleted file mode 100644 index 551f6eafb..000000000 --- a/frontend-modern/src/components/Docker/runtimeDisplay.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { DockerContainer, DockerHost } from '@/types/api'; - -export type RuntimeKind = 'docker' | 'podman' | 'containerd' | 'cri-o' | 'nerdctl' | 'unknown'; - -export interface RuntimeDisplayInfo { - id: RuntimeKind; - label: string; - badgeClass: string; - raw: string; -} - -export interface RuntimeDisplayOptions { - hint?: string | null; - defaultId?: Exclude | 'unknown'; -} - -const BADGE_CLASSES: Record, string> = { - docker: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', - podman: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', - containerd: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', - 'cri-o': 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', - nerdctl: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-200', -}; - -const UNKNOWN_BADGE_CLASS = 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'; - -const LABEL_BY_KIND: Record = { - docker: 'Docker', - podman: 'Podman', - containerd: 'containerd', - 'cri-o': 'CRI-O', - nerdctl: 'nerdctl', - unknown: 'Container runtime', -}; - -const normalize = (value?: string | null) => value?.trim() ?? ''; - -const MATCHERS: Array<{ id: Exclude; keywords: string[] }> = [ - { id: 'podman', keywords: ['podman', 'libpod'] }, - { id: 'nerdctl', keywords: ['nerdctl'] }, - { id: 'containerd', keywords: ['containerd'] }, - { id: 'cri-o', keywords: ['cri-o', 'crio'] }, - { id: 'docker', keywords: ['docker', 'moby engine', 'moby'] }, -]; - -const detectRuntime = (value?: string | null) => { - const raw = normalize(value); - if (!raw) { - return null; - } - - const normalized = raw.toLowerCase(); - for (const matcher of MATCHERS) { - if (matcher.keywords.some((keyword) => normalized.includes(keyword))) { - return { id: matcher.id, raw }; - } - } - - return null; -}; - -const formatRuntimeLabel = (value: string): string => { - if (!value) return LABEL_BY_KIND.unknown; - - const cleaned = value.replace(/\s+/g, ' ').trim(); - - if (/^cri-?o$/i.test(cleaned)) { - return LABEL_BY_KIND['cri-o']; - } - - return cleaned - .split(/[-_\s]+/) - .filter(Boolean) - .map((segment) => { - if (segment.toLowerCase() === 'cri' || segment.toLowerCase() === 'crio') { - return 'CRI'; - } - if (segment.toLowerCase() === 'o') { - return 'O'; - } - if (segment === segment.toUpperCase()) { - return segment; - } - return segment.charAt(0).toUpperCase() + segment.slice(1); - }) - .join(' '); -}; - -export const getRuntimeDisplay = ( - runtime?: string | null, - options: RuntimeDisplayOptions = {}, -): RuntimeDisplayInfo => { - const candidates = [ - { value: runtime, raw: normalize(runtime) }, - { value: options.hint, raw: normalize(options.hint) }, - ]; - - for (const candidate of candidates) { - const detected = detectRuntime(candidate.value); - if (detected) { - const label = LABEL_BY_KIND[detected.id]; - const badgeClass = BADGE_CLASSES[detected.id]; - return { - id: detected.id, - label, - badgeClass, - raw: detected.raw, - }; - } - } - - const firstRaw = candidates.find((candidate) => candidate.raw !== '')?.raw ?? ''; - const defaultKind: RuntimeKind = options.defaultId ?? 'unknown'; - - if (defaultKind !== 'unknown') { - return { - id: defaultKind, - label: LABEL_BY_KIND[defaultKind], - badgeClass: BADGE_CLASSES[defaultKind], - raw: firstRaw, - }; - } - - const label = firstRaw ? formatRuntimeLabel(firstRaw) : LABEL_BY_KIND.unknown; - - return { - id: 'unknown', - label, - badgeClass: UNKNOWN_BADGE_CLASS, - raw: firstRaw, - }; -}; - -const PODMAN_LABEL_PREFIXES = ['io.podman.', 'io.containers.', 'net.containers.podman']; - -const hasPodmanSignature = (container?: DockerContainer | null) => { - if (!container?.labels) return false; - const labels = container.labels; - return Object.keys(labels).some((key) => - PODMAN_LABEL_PREFIXES.some((prefix) => key.startsWith(prefix)), - ) || Object.values(labels).some((value) => value?.toLowerCase().includes('podman')); -}; - -const hostHasPodmanSignature = (host?: Pick | null) => { - if (!host?.containers || host.containers.length === 0) return false; - return host.containers.some((container) => hasPodmanSignature(container)); -}; - -export interface ResolveRuntimeOptions extends RuntimeDisplayOptions { - requireSignatureForGuess?: boolean; -} - -const stripVersion = (value?: string | null) => { - if (!value) return ''; - const match = value.match(/\d+(?:\.\d+){0,2}/); - return match ? match[0] : value.trim(); -}; - -const isLikelyPodmanVersion = (value?: string | null) => { - const clean = stripVersion(value); - if (!clean) return false; - const parts = clean.split('.'); - const major = Number.parseInt(parts[0] || '', 10); - if (!Number.isFinite(major)) return false; - return major >= 2 && major <= 6; -}; - -export const resolveHostRuntime = ( - host: Pick, - options: ResolveRuntimeOptions = {}, -): RuntimeDisplayInfo => { - const hint = options.hint ?? host.runtimeVersion ?? host.dockerVersion ?? null; - const base = getRuntimeDisplay(host.runtime, { - ...options, - hint, - defaultId: options.defaultId ?? 'docker', - }); - if (base.id !== 'docker' && base.id !== 'unknown') { - return base; - } - - const hasSignature = hostHasPodmanSignature(host); - if (hasSignature) { - return getRuntimeDisplay('podman', { hint }); - } - - if (!options.requireSignatureForGuess && isLikelyPodmanVersion(hint)) { - return getRuntimeDisplay('podman', { hint }); - } - - if (base.id === 'unknown') { - return getRuntimeDisplay('docker', { hint }); - } - - return base; -}; diff --git a/frontend-modern/src/components/Docker/swarmClusterHelpers.ts b/frontend-modern/src/components/Docker/swarmClusterHelpers.ts deleted file mode 100644 index 36bfbc387..000000000 --- a/frontend-modern/src/components/Docker/swarmClusterHelpers.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { DockerHost, DockerService } from '@/types/api'; - -/** - * Represents a Docker Swarm cluster with its member hosts - */ -export interface SwarmCluster { - clusterId: string; - clusterName: string; - hosts: DockerHost[]; -} - -/** - * Represents an aggregated service across a Swarm cluster - */ -export interface ClusterService { - service: DockerService; - clusterId: string; - clusterName: string; - nodes: Array<{ - hostId: string; - hostname: string; - taskCount: number; - runningCount: number; - }>; - totalDesired: number; - totalRunning: number; -} - -/** - * Groups Docker hosts by their Swarm cluster ID. - * Hosts without a clusterId are returned as individual "clusters" with empty clusterId. - */ -export function groupHostsByCluster(hosts: DockerHost[]): SwarmCluster[] { - const clusterMap = new Map(); - const standaloneHosts: DockerHost[] = []; - - for (const host of hosts) { - const clusterId = host.swarm?.clusterId; - - if (!clusterId) { - // Non-Swarm host or no cluster info - standaloneHosts.push(host); - continue; - } - - if (!clusterMap.has(clusterId)) { - clusterMap.set(clusterId, { - clusterId, - clusterName: host.swarm?.clusterName || clusterId.slice(0, 12), - hosts: [], - }); - } - - clusterMap.get(clusterId)!.hosts.push(host); - } - - // Sort hosts within each cluster by hostname - for (const cluster of clusterMap.values()) { - cluster.hosts.sort((a, b) => { - const aName = a.customDisplayName || a.displayName || a.hostname || ''; - const bName = b.customDisplayName || b.displayName || b.hostname || ''; - return aName.localeCompare(bName); - }); - } - - // Convert to array and sort by cluster name - const clusters = Array.from(clusterMap.values()).sort((a, b) => - a.clusterName.localeCompare(b.clusterName) - ); - - return clusters; -} - -/** - * Aggregates services across all hosts in a cluster, deduplicating by service ID. - * Returns services with node distribution information. - */ -export function aggregateClusterServices(cluster: SwarmCluster): ClusterService[] { - const serviceMap = new Map(); - - for (const host of cluster.hosts) { - const services = host.services || []; - const tasks = host.tasks || []; - - for (const service of services) { - const serviceId = service.id; - - if (!serviceMap.has(serviceId)) { - serviceMap.set(serviceId, { - service: { ...service }, - clusterId: cluster.clusterId, - clusterName: cluster.clusterName, - nodes: [], - totalDesired: service.desiredTasks || 0, - totalRunning: 0, - }); - } - - const aggregated = serviceMap.get(serviceId)!; - - // Count tasks for this service on this host - const serviceTasks = tasks.filter( - (t) => t.serviceId === serviceId || t.serviceName === service.name - ); - const runningTasks = serviceTasks.filter( - (t) => t.currentState?.toLowerCase() === 'running' - ); - - if (serviceTasks.length > 0) { - aggregated.nodes.push({ - hostId: host.id, - hostname: host.customDisplayName || host.displayName || host.hostname, - taskCount: serviceTasks.length, - runningCount: runningTasks.length, - }); - aggregated.totalRunning += runningTasks.length; - } - - // Update desired count (take max in case of inconsistencies) - aggregated.totalDesired = Math.max( - aggregated.totalDesired, - service.desiredTasks || 0 - ); - } - } - - // Sort services by stack then name - return Array.from(serviceMap.values()).sort((a, b) => { - const stackA = a.service.stack || ''; - const stackB = b.service.stack || ''; - if (stackA !== stackB) { - return stackA.localeCompare(stackB); - } - return (a.service.name || '').localeCompare(b.service.name || ''); - }); -} - -/** - * Checks if there are any Swarm clusters with multiple hosts. - * Used to determine whether to show the cluster view toggle. - */ -export function hasSwarmClusters(hosts: DockerHost[]): boolean { - const clusterIds = new Set(); - - for (const host of hosts) { - const clusterId = host.swarm?.clusterId; - if (clusterId) { - if (clusterIds.has(clusterId)) { - // Found at least 2 hosts in the same cluster - return true; - } - clusterIds.add(clusterId); - } - } - - return false; -} - -/** - * Gets a display-friendly node list string for a service. - * Example: "node1 (2), node2 (1), node3 (1)" - */ -export function formatNodeDistribution( - nodes: ClusterService['nodes'], - maxNodes: number = 3 -): string { - if (nodes.length === 0) return 'No nodes'; - - const sorted = [...nodes].sort((a, b) => b.taskCount - a.taskCount); - const display = sorted.slice(0, maxNodes); - const remaining = sorted.length - maxNodes; - - const parts = display.map((n) => { - if (n.taskCount === 1) return n.hostname; - return `${n.hostname} (${n.taskCount})`; - }); - - if (remaining > 0) { - parts.push(`+${remaining} more`); - } - - return parts.join(', '); -} - -/** - * Gets the overall health status for a cluster service. - */ -export function getServiceHealthStatus( - service: ClusterService -): 'healthy' | 'degraded' | 'critical' { - const { totalDesired, totalRunning } = service; - - if (totalDesired === 0) return 'healthy'; - if (totalRunning === 0) return 'critical'; - if (totalRunning < totalDesired) return 'degraded'; - return 'healthy'; -} diff --git a/frontend-modern/src/components/Hosts/HostDrawer.tsx b/frontend-modern/src/components/Hosts/HostDrawer.tsx deleted file mode 100644 index e92ccd51d..000000000 --- a/frontend-modern/src/components/Hosts/HostDrawer.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { Component, Show, Suspense, createSignal } from 'solid-js'; -import { Host } from '@/types/api'; -import { HistoryChart } from '../shared/HistoryChart'; -import { ResourceType, HistoryTimeRange } from '@/api/charts'; -import { hasFeature } from '@/stores/license'; -import { DiscoveryTab } from '../Discovery/DiscoveryTab'; -import { SystemInfoCard } from '@/components/shared/cards/SystemInfoCard'; -import { HardwareCard } from '@/components/shared/cards/HardwareCard'; -import { NetworkInterfacesCard } from '@/components/shared/cards/NetworkInterfacesCard'; -import { DisksCard } from '@/components/shared/cards/DisksCard'; -import { TemperaturesCard } from '@/components/shared/cards/TemperaturesCard'; -import { formatTemperature } from '@/utils/temperature'; - -interface HostDrawerProps { - host: Host; - onClose: () => void; - customUrl?: string; - onCustomUrlChange?: (hostId: string, url: string) => void; -} - -export const HostDrawer: Component = (props) => { - const [activeTab, setActiveTab] = createSignal<'overview' | 'discovery'>('overview'); - const [historyRange, setHistoryRange] = createSignal('1h'); - - // For unified host agents, the backend stores metrics with resourceType 'host' - const metricsResource = { type: 'host' as ResourceType, id: props.host.id }; - - const switchTab = (tab: 'overview' | 'discovery') => { - setActiveTab(tab); - }; - - const isHistoryLocked = () => !hasFeature('long_term_metrics') && (historyRange() === '30d' || historyRange() === '90d'); - - const formatSensorName = (name: string) => { - let clean = name.replace(/^[a-z]+\d*_/i, ''); - clean = clean.replace(/_/g, ' '); - return clean.replace(/\b\w/g, c => c.toUpperCase()); - }; - - const temperatureRows = () => { - const rows: { label: string; value: string; valueTitle?: string }[] = []; - const temps = props.host.sensors?.temperatureCelsius; - if (temps) { - const entries = Object.entries(temps).sort(([a], [b]) => a.localeCompare(b)); - entries.forEach(([name, temp]) => { - rows.push({ - label: formatSensorName(name), - value: formatTemperature(temp), - valueTitle: `${temp.toFixed(1)}°C`, - }); - }); - } - - const smart = props.host.sensors?.smart; - if (smart) { - smart - .filter(disk => !disk.standby && Number.isFinite(disk.temperature)) - .sort((a, b) => a.device.localeCompare(b.device)) - .forEach(disk => { - rows.push({ - label: `Disk ${disk.device}`, - value: formatTemperature(disk.temperature), - valueTitle: `${disk.temperature.toFixed(1)}°C`, - }); - }); - } - return rows; - }; - - return ( -
- {/* Tabs */} -
- - -
- - {/* Overview Tab */} -
-
- - - - - -
- - {/* Performance Charts */} -
-
- - - - - -
- -
-
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
- {/* Lock Overlay */} - -
-
- - - - -
-

{historyRange() === '30d' ? '30' : '90'}-Day History

-

- Upgrade to Pulse Pro to unlock {historyRange() === '30d' ? '30' : '90'} days of historical data retention. -

- - Unlock Pro Features - -
-
-
-
-
- - {/* Discovery Tab */} -
- -
- Loading discovery... -
- }> - props.onCustomUrlChange?.(props.host.id, url)} - commandsEnabled={props.host.commandsEnabled} - /> -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Hosts/HostsFilter.tsx b/frontend-modern/src/components/Hosts/HostsFilter.tsx deleted file mode 100644 index 1e386085d..000000000 --- a/frontend-modern/src/components/Hosts/HostsFilter.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js'; -import { Card } from '@/components/shared/Card'; -import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover'; -import { ColumnPicker } from '@/components/shared/ColumnPicker'; -import type { ColumnDef } from '@/hooks/useColumnVisibility'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { createSearchHistoryManager } from '@/utils/searchHistory'; - -interface HostsFilterProps { - search: () => string; - setSearch: (value: string) => void; - statusFilter: () => 'all' | 'online' | 'degraded' | 'offline'; - setStatusFilter: (value: 'all' | 'online' | 'degraded' | 'offline') => void; - searchInputRef?: (el: HTMLInputElement) => void; - onReset?: () => void; - activeHostName?: string; - onClearHost?: () => void; - // Column visibility - availableColumns?: ColumnDef[]; - isColumnHidden?: (id: string) => boolean; - onColumnToggle?: (id: string) => void; - onColumnReset?: () => void; -} - -export const HostsFilter: Component = (props) => { - const historyManager = createSearchHistoryManager(STORAGE_KEYS.HOSTS_SEARCH_HISTORY); - const [searchHistory, setSearchHistory] = createSignal([]); - const [isHistoryOpen, setIsHistoryOpen] = createSignal(false); - - let searchInputEl: HTMLInputElement | undefined; - let historyMenuRef: HTMLDivElement | undefined; - let historyToggleRef: HTMLButtonElement | undefined; - - onMount(() => { - setSearchHistory(historyManager.read()); - }); - - const commitSearchToHistory = (term: string) => { - const trimmed = term.trim(); - if (!trimmed) return; - const updated = historyManager.add(trimmed); - setSearchHistory(updated); - }; - - const deleteHistoryEntry = (term: string) => { - setSearchHistory(historyManager.remove(term)); - }; - - const clearHistory = () => { - setSearchHistory(historyManager.clear()); - setIsHistoryOpen(false); - queueMicrotask(() => historyToggleRef?.blur()); - }; - - const closeHistory = () => { - setIsHistoryOpen(false); - queueMicrotask(() => historyToggleRef?.blur()); - }; - - const handleDocumentClick = (event: MouseEvent) => { - const target = event.target as Node; - const clickedMenu = historyMenuRef?.contains(target) ?? false; - const clickedToggle = historyToggleRef?.contains(target) ?? false; - if (!clickedMenu && !clickedToggle) { - closeHistory(); - } - }; - - createEffect(() => { - if (isHistoryOpen()) { - document.addEventListener('mousedown', handleDocumentClick); - } else { - document.removeEventListener('mousedown', handleDocumentClick); - } - }); - - onCleanup(() => { - document.removeEventListener('mousedown', handleDocumentClick); - }); - - const focusSearchInput = () => { - queueMicrotask(() => searchInputEl?.focus()); - }; - - let suppressBlurCommit = false; - - const markSuppressCommit = () => { - suppressBlurCommit = true; - queueMicrotask(() => { - suppressBlurCommit = false; - }); - }; - - const hasActiveFilters = createMemo( - () => - props.search().trim() !== '' || - Boolean(props.activeHostName) || - props.statusFilter() !== 'all', - ); - - const handleReset = () => { - props.setSearch(''); - props.setStatusFilter('all'); - props.onClearHost?.(); - props.onReset?.(); - closeHistory(); - focusSearchInput(); - }; - - return ( - -
- {/* Search - full width on its own row */} -
- { - searchInputEl = el; - props.searchInputRef?.(el); - }} - type="text" - placeholder="Search hosts by hostname, platform, or OS..." - value={props.search()} - onInput={(e) => props.setSearch(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - commitSearchToHistory(e.currentTarget.value); - closeHistory(); - } else if (e.key === 'Escape') { - props.setSearch(''); - closeHistory(); - e.currentTarget.blur(); - } else if (e.key === 'ArrowDown' && searchHistory().length > 0) { - e.preventDefault(); - setIsHistoryOpen(true); - } - }} - onBlur={(e) => { - if (suppressBlurCommit) return; - const next = e.relatedTarget as HTMLElement | null; - const interactingWithHistory = next - ? historyMenuRef?.contains(next) || historyToggleRef?.contains(next) - : false; - const interactingWithTips = - next?.getAttribute('aria-controls') === 'hosts-search-help'; - if (!interactingWithHistory && !interactingWithTips) { - commitSearchToHistory(e.currentTarget.value); - } - }} - class="w-full pl-9 pr-16 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" - title="Search hosts by hostname, platform, or OS" - /> - - - - - - -
- - -
- -
(historyMenuRef = el)} - class="absolute left-0 right-0 top-full z-50 mt-2 w-full overflow-hidden rounded-lg border border-gray-200 bg-white text-sm shadow-xl dark:border-gray-700 dark:bg-gray-800" - role="listbox" - > - 0} - fallback={ -
- Searches you run will appear here. -
- } - > -
- - {(entry) => ( -
- - -
- )} -
-
- -
-
-
-
- - {/* Filters - second row */} -
-
- - - - -
- - -
- Host: {props.activeHostName} - -
-
- - {/* Column Picker */} - - - - - - - - - -
-
-
- ); -}; diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx deleted file mode 100644 index 4e8ced2d0..000000000 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ /dev/null @@ -1,1388 +0,0 @@ -import type { Component, JSX } from 'solid-js'; -import { For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { useNavigate } from '@solidjs/router'; -import type { Host, HostRAIDArray } from '@/types/api'; -import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; -import { formatTemperature } from '@/utils/temperature'; -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { StackedDiskBar } from '@/components/Dashboard/StackedDiskBar'; -import { HostsFilter } from './HostsFilter'; -import { useWebSocket } from '@/App'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { getHostStatusIndicator } from '@/utils/status'; - -import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; -import { EnhancedCPUBar } from '@/components/Dashboard/EnhancedCPUBar'; -import { useBreakpoint, type ColumnPriority } from '@/hooks/useBreakpoint'; -import { useColumnVisibility } from '@/hooks/useColumnVisibility'; -import { STORAGE_KEYS } from '@/utils/localStorage'; -import { useResourcesAsLegacy } from '@/hooks/useResources'; -import { useAlertsActivation } from '@/stores/alertsActivation'; -import { HostMetadataAPI, type HostMetadata } from '@/api/hostMetadata'; - -import { logger } from '@/utils/logger'; -import { buildMetricKey } from '@/utils/metricsKeys'; -import { isKioskMode, subscribeToKioskMode } from '@/utils/url'; -import { HostDrawer } from './HostDrawer'; - -// Column definition for hosts table -export interface HostColumnDef { - id: string; - label: string; - icon?: JSX.Element; - priority: ColumnPriority; - toggleable?: boolean; - width?: string; - sortKey?: string; -} - -// Host table column definitions - all essential for horizontal scroll like Docker -export const HOST_COLUMNS: HostColumnDef[] = [ - // Core columns - all essential (visible on all screens with horizontal scroll) - { id: 'name', label: 'Host', priority: 'essential', width: '210px', sortKey: 'name' }, - { id: 'platform', label: 'Platform', priority: 'essential', width: '110px', sortKey: 'platform' }, - { id: 'cpu', label: 'CPU', priority: 'essential', width: '60px', sortKey: 'cpu' }, - { id: 'memory', label: 'Memory', priority: 'essential', width: '60px', sortKey: 'memory' }, - { id: 'disk', label: 'Disk', priority: 'essential', width: '220px', sortKey: 'disk' }, - - // Additional columns - essential but toggleable by user - { id: 'temp', label: 'Temp', icon: , priority: 'essential', width: '50px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: , priority: 'essential', width: '55px', toggleable: true, sortKey: 'uptime' }, - { id: 'agent', label: 'Agent', priority: 'essential', width: '55px', toggleable: true }, - { id: 'ip', label: 'IP', icon: , priority: 'essential', width: '45px', toggleable: true }, - { id: 'arch', label: 'Arch', priority: 'essential', width: '50px', toggleable: true }, - { id: 'kernel', label: 'Kernel', priority: 'essential', width: '80px', toggleable: true }, - { id: 'raid', label: 'RAID', priority: 'essential', width: '55px', toggleable: true }, -]; - -// Network info cell with rich tooltip showing interfaces, IPs, and traffic (matches GuestRow pattern) -interface NetworkInterface { - name: string; - addresses?: string[]; - mac?: string; - rxBytes?: number; - txBytes?: number; -} - -function HostNetworkInfoCell(props: { networkInterfaces: NetworkInterface[] }) { - const [showTooltip, setShowTooltip] = createSignal(false); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - - const hasInterfaces = () => props.networkInterfaces.length > 0; - - const totalIps = () => { - return props.networkInterfaces.reduce((sum, iface) => sum + (iface.addresses?.length || 0), 0); - }; - - const handleMouseEnter = (e: MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); - setShowTooltip(true); - }; - - const handleMouseLeave = () => { - setShowTooltip(false); - }; - - return ( - <> - - }> - {/* Network globe icon */} - - - - {totalIps()} - - - - - -
-
-
- Network Interfaces -
- - - {(iface, idx) => ( -
0 }}> -
- {iface.name || 'eth' + idx()} - - {iface.mac} - -
- 0}> -
- - {(ip) => ( - {ip} - )} - -
-
- - No IP assigned - - 0 || (iface.txBytes || 0) > 0}> -
- RX: {formatBytes(iface.rxBytes || 0)} / TX: {formatBytes(iface.txBytes || 0)} -
-
-
- )} -
-
-
-
-
- - ); -} - -// Temperature cell with rich tooltip showing all sensor readings -interface HostDiskSMARTForCell { - device: string; - model?: string; - temperature: number; - health?: string; - standby?: boolean; -} - -interface HostSensorSummaryForCell { - temperatureCelsius?: Record; - fanRpm?: Record; - additional?: Record; - smart?: HostDiskSMARTForCell[]; -} - -function HostTemperatureCell(props: { sensors: HostSensorSummaryForCell | null | undefined }) { - const [showTooltip, setShowTooltip] = createSignal(false); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - const [tooltipDirection, setTooltipDirection] = createSignal<'above' | 'below'>('above'); - const alertsActivation = useAlertsActivation(); - const threshold = createMemo(() => alertsActivation.getTemperatureThreshold()); - let closeTimeout: number | undefined; - - // Get the primary (highest) temperature for display - const primaryTemp = createMemo(() => { - // First try CPU/sensor temperatures - if (props.sensors?.temperatureCelsius) { - const temps = Object.values(props.sensors.temperatureCelsius); - if (temps.length > 0) { - // Find package/composite temp first, otherwise show max - const keys = Object.keys(props.sensors.temperatureCelsius); - const packageKey = keys.find(k => - k.toLowerCase().includes('package') || - k.toLowerCase().includes('composite') || - k.toLowerCase().includes('tctl') - ); - if (packageKey) return props.sensors.temperatureCelsius[packageKey]; - return Math.max(...temps); - } - } - // Fall back to max SMART disk temperature if no sensor temps - if (props.sensors?.smart && props.sensors.smart.length > 0) { - const diskTemps = props.sensors.smart - .filter(d => !d.standby && d.temperature > 0) - .map(d => d.temperature); - if (diskTemps.length > 0) { - return Math.max(...diskTemps); - } - } - return null; - }); - - const hasSensors = () => { - const temps = props.sensors?.temperatureCelsius; - const fans = props.sensors?.fanRpm; - const additional = props.sensors?.additional; - const smart = props.sensors?.smart; - return (temps && Object.keys(temps).length > 0) || - (fans && Object.keys(fans).length > 0) || - (additional && Object.keys(additional).length > 0) || - (smart && smart.length > 0); - }; - - // Color based on temperature - const textColorClass = createMemo(() => { - const temp = primaryTemp(); - if (temp === null) return 'text-gray-400'; - const critical = threshold(); - const warning = Math.max(0, critical - 5); - - if (temp >= critical) return 'text-red-600 dark:text-red-400'; - if (temp >= warning) return 'text-yellow-600 dark:text-yellow-400'; - return 'text-gray-600 dark:text-gray-400'; - }); - - const tooltipColorClass = (temp: number) => { - const critical = threshold(); - const warning = Math.max(0, critical - 5); - if (temp >= critical) return 'text-red-400'; - if (temp >= warning) return 'text-yellow-400'; - return 'text-gray-200'; - } - - const handleMouseEnter = (e: MouseEvent) => { - if (closeTimeout) window.clearTimeout(closeTimeout); - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top; - - // Estimate tooltip height (~400px max) and check if it fits above - const estimatedTooltipHeight = 400; - const spaceAbove = rect.top; - const spaceBelow = window.innerHeight - rect.bottom; - - // Position below if not enough space above, but also check if below has more space - if (spaceAbove < estimatedTooltipHeight && spaceBelow > spaceAbove) { - setTooltipDirection('below'); - setTooltipPos({ x, y: rect.bottom }); - } else { - setTooltipDirection('above'); - setTooltipPos({ x, y }); - } - setShowTooltip(true); - }; - - const handleMouseLeave = () => { - closeTimeout = window.setTimeout(() => { - setShowTooltip(false); - }, 150); - }; - - const handleTooltipEnter = () => { - if (closeTimeout) window.clearTimeout(closeTimeout); - }; - - const handleTooltipLeave = () => { - setShowTooltip(false); - }; - - // Sort sensors: package/composite first, then cores, then others - const sortedTemps = createMemo(() => { - if (!props.sensors?.temperatureCelsius) return []; - return Object.entries(props.sensors.temperatureCelsius).sort(([a], [b]) => { - const aLower = a.toLowerCase(); - const bLower = b.toLowerCase(); - // Package/composite/tctl first - const aIsPrimary = aLower.includes('package') || aLower.includes('composite') || aLower.includes('tctl'); - const bIsPrimary = bLower.includes('package') || bLower.includes('composite') || bLower.includes('tctl'); - if (aIsPrimary && !bIsPrimary) return -1; - if (bIsPrimary && !aIsPrimary) return 1; - // Then cores by number - const aCoreMatch = aLower.match(/core\s*(\d+)/); - const bCoreMatch = bLower.match(/core\s*(\d+)/); - if (aCoreMatch && bCoreMatch) { - return parseInt(aCoreMatch[1]) - parseInt(bCoreMatch[1]); - } - return a.localeCompare(b); - }); - }); - - const sortedFans = createMemo(() => { - if (!props.sensors?.fanRpm) return []; - return Object.entries(props.sensors.fanRpm).sort(([a], [b]) => a.localeCompare(b)); - }); - - const sortedAdditional = createMemo(() => { - if (!props.sensors?.additional) return []; - return Object.entries(props.sensors.additional).sort(([a], [b]) => a.localeCompare(b)); - }); - - // Sort SMART disks by device name - const sortedSmart = createMemo(() => { - if (!props.sensors?.smart) return []; - return [...props.sensors.smart].sort((a, b) => a.device.localeCompare(b.device)); - }); - - // Format sensor name for display (e.g., "nct6687_cpu_fan" -> "CPU Fan") - const formatSensorName = (name: string) => { - // Remove chip prefix (e.g., "nct6687_" or "coretemp_") - let clean = name.replace(/^[a-z]+\d*_/i, ''); - // Replace underscores with spaces - clean = clean.replace(/_/g, ' '); - // Capitalize words - return clean.replace(/\b\w/g, c => c.toUpperCase()); - }; - - return ( - <> - - }> - {formatTemperature(primaryTemp())} - - - - - -
-
- {/* Temperature section */} - 0}> -
- Temperatures -
-
- - {([name, temp]) => { - return ( -
- {formatSensorName(name)} - {formatTemperature(temp)} -
- ); - }} -
-
-
- - {/* Disk temperatures section (SMART) */} - 0}> -
- Disk Temperatures -
-
- - {(disk) => { - if (disk.standby) { - return ( -
- - {disk.device} - - standby -
- ); - } - const temp = disk.temperature; - // For HDDs, 45°C is warm, 50°C+ is hot; for SSDs use higher thresholds - const colorClass = temp >= 55 ? 'text-red-400' : temp >= 45 ? 'text-yellow-400' : 'text-gray-200'; - return ( -
- - {disk.device} - - {formatTemperature(temp)} -
- ); - }} -
-
-
- - {/* Fan speeds section */} - 0}> -
- Fan Speeds -
-
- - {([name, rpm]) => ( -
- {formatSensorName(name)} - {Math.round(rpm)} RPM -
- )} -
-
-
- - {/* Additional sensors section */} - 0}> -
- Other Sensors -
-
- - {([name, temp]) => { - return ( -
- {formatSensorName(name)} - {formatTemperature(temp)} -
- ); - }} -
-
-
-
-
-
-
- - ); -} - -// RAID status cell with rich tooltip showing array details -interface HostRAIDStatusCellProps { - raid: HostRAIDArray[] | undefined; -} - -function HostRAIDStatusCell(props: HostRAIDStatusCellProps) { - const [showTooltip, setShowTooltip] = createSignal(false); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - - const hasArrays = () => props.raid && props.raid.length > 0; - - // Analyze overall status - const status = createMemo(() => { - if (!props.raid || props.raid.length === 0) { - return { type: 'none' as const, label: '-', color: 'text-gray-400' }; - } - - let hasDegraded = false; - let hasRebuilding = false; - let maxRebuildPercent = 0; - - for (const array of props.raid) { - const state = array.state.toLowerCase(); - if (state.includes('degraded') || array.failedDevices > 0) { - hasDegraded = true; - } - if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) { - hasRebuilding = true; - maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent); - } - } - - if (hasDegraded) { - return { type: 'degraded' as const, label: 'Degraded', color: 'text-red-600 dark:text-red-400' }; - } - if (hasRebuilding) { - return { - type: 'rebuilding' as const, - label: `${Math.round(maxRebuildPercent)}%`, - color: 'text-amber-600 dark:text-amber-400' - }; - } - return { type: 'ok' as const, label: 'OK', color: 'text-green-600 dark:text-green-400' }; - }); - - const handleMouseEnter = (e: MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); - setShowTooltip(true); - }; - - const handleMouseLeave = () => { - setShowTooltip(false); - }; - - // Get state color for individual devices - const getDeviceStateColor = (state: string) => { - const s = state.toLowerCase(); - if (s.includes('active') || s.includes('sync')) return 'text-green-400'; - if (s.includes('spare')) return 'text-blue-400'; - if (s.includes('faulty') || s.includes('removed')) return 'text-red-400'; - if (s.includes('rebuilding')) return 'text-amber-400'; - return 'text-gray-400'; - }; - - // Get array state color - const getArrayStateColor = (array: HostRAIDArray) => { - const state = array.state.toLowerCase(); - if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400'; - if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400'; - if (state.includes('clean') || state.includes('active')) return 'text-green-400'; - return 'text-gray-400'; - }; - - return ( - <> - - }> - {/* Status icon */} - - - - - - - - - - - - - - - - - {props.raid!.length > 1 ? `${props.raid!.length} ` : ''}{status().label} - - - - - - -
-
-
- - - - RAID Arrays ({props.raid!.length}) -
- -
- - {(array) => ( -
- {/* Array header */} -
-
- {array.device} - {array.level} -
- - {array.state} - -
- - {/* Array name if present */} - -
{array.name}
-
- - {/* Device counts */} -
- Active: {array.activeDevices} - Working: {array.workingDevices} - 0}> - Spare: {array.spareDevices} - - 0}> - Failed: {array.failedDevices} - -
- - {/* Rebuild progress */} - 0}> -
-
- Rebuilding - {array.rebuildPercent.toFixed(1)}% -
-
-
-
- -
- Speed: {array.rebuildSpeed} -
-
-
- - - {/* Individual devices */} - 0}> -
- - {(dev) => ( - - {dev.device.replace('/dev/', '')} - - )} - -
-
-
- )} - -
-
-
- - - - ); -} - -type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; -export const HostsOverview: Component = () => { - const navigate = useNavigate(); - const wsContext = useWebSocket(); - const [search, setSearch] = createSignal(''); - const [statusFilter, setStatusFilter] = createSignal<'all' | 'online' | 'degraded' | 'offline'>('all'); - const [sortKey, setSortKey] = createSignal('name'); - const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc'); - const { isMobile } = useBreakpoint(); - - // Use the hook directly to ensure reactivity is maintained - // This fixes the issue where props.hosts would not update when the underlying data changes - const { asHosts } = useResourcesAsLegacy(); - - const [expandedHostId, setExpandedHostId] = createSignal(null); - - const toggleHostExpand = (hostId: string) => { - setExpandedHostId(prev => prev === hostId ? null : hostId); - }; - - // Column visibility management - const columnVisibility = useColumnVisibility( - STORAGE_KEYS.HOSTS_HIDDEN_COLUMNS, - HOST_COLUMNS as HostColumnDef[] - ); - const visibleColumns = columnVisibility.visibleColumns; - const visibleColumnIds = createMemo(() => visibleColumns().map(c => c.id)); - const isColVisible = (colId: string) => visibleColumnIds().includes(colId); - - // Kiosk mode - hide filter panel for clean dashboard display - const [kioskMode, setKioskMode] = createSignal(isKioskMode()); - - // Subscribe to kiosk mode changes from toggle button - onMount(() => { - const unsubscribe = subscribeToKioskMode((enabled) => { - setKioskMode(enabled); - }); - return unsubscribe; - }); - - // Host metadata management (for custom URLs) - const [hostMetadata, setHostMetadata] = createSignal>({}); - const [hostMetadataVersion, setHostMetadataVersion] = createSignal(0); - - // Load host metadata on mount - createEffect(() => { - HostMetadataAPI.getAllMetadata() - .then(data => { - setHostMetadata(data || {}); - logger.debug('Loaded host metadata', { count: Object.keys(data || {}).length }); - }) - .catch(err => { - logger.warn('Failed to load host metadata', { error: err }); - }); - }); - - // Get custom URL for a host - const getHostCustomUrl = (hostId: string): string | undefined => { - // Access version to trigger reactivity when metadata changes - hostMetadataVersion(); - return hostMetadata()[hostId]?.customUrl; - }; - - // Update custom URL for a host - const updateHostCustomUrl = async (hostId: string, url: string): Promise => { - try { - await HostMetadataAPI.updateMetadata(hostId, { customUrl: url }); - setHostMetadata(prev => ({ - ...prev, - [hostId]: { ...prev[hostId], id: hostId, customUrl: url } - })); - setHostMetadataVersion(v => v + 1); - logger.info('Updated host custom URL', { hostId, url }); - return true; - } catch (err) { - logger.error('Failed to update host custom URL', { hostId, url, error: err }); - return false; - } - }; - - // Delete custom URL for a host - const deleteHostCustomUrl = async (hostId: string): Promise => { - try { - await HostMetadataAPI.deleteMetadata(hostId); - setHostMetadata(prev => { - const next = { ...prev }; - delete next[hostId]; - return next; - }); - setHostMetadataVersion(v => v + 1); - logger.info('Deleted host custom URL', { hostId }); - return true; - } catch (err) { - logger.error('Failed to delete host custom URL', { hostId, error: err }); - return false; - } - }; - - const handleSort = (key: SortKey) => { - if (sortKey() === key) { - setSortDirection(sortDirection() === 'asc' ? 'desc' : 'asc'); - } else { - setSortKey(key); - setSortDirection('asc'); - } - }; - - const renderSortIndicator = (key: SortKey) => { - if (sortKey() !== key) return null; - return sortDirection() === 'asc' ? '▲' : '▼'; - }; - - // Keyboard listener to auto-focus search - let searchInputRef: HTMLInputElement | undefined; - - const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } - if (e.ctrlKey || e.metaKey || e.altKey) { - return; - } - if (e.key.length === 1 && searchInputRef) { - e.preventDefault(); - searchInputRef.focus(); - setSearch(search() + e.key); - } - }; - - onMount(() => { - document.addEventListener('keydown', handleKeyDown); - }); - - onCleanup(() => { - document.removeEventListener('keydown', handleKeyDown); - }); - - const connected = () => wsContext.connected(); - const reconnecting = () => wsContext.reconnecting(); - const reconnect = () => wsContext.reconnect(); - - // Access asHosts() directly inside the memo to maintain reactivity - const hosts = () => asHosts() as Host[]; - - const isInitialLoading = createMemo(() => { - return !connected() && !reconnecting() && hosts().length === 0; - }); - - const sortedHosts = createMemo(() => { - const hostList = [...hosts()]; - const key = sortKey(); - const direction = sortDirection(); - - return hostList.sort((a: Host, b: Host) => { - let comparison = 0; - switch (key) { - case 'name': - comparison = (a.displayName || a.hostname || a.id).localeCompare(b.displayName || b.hostname || b.id); - break; - case 'platform': - comparison = (a.platform || '').localeCompare(b.platform || ''); - break; - case 'cpu': - comparison = (a.cpuUsage ?? 0) - (b.cpuUsage ?? 0); - break; - case 'memory': - comparison = (a.memory?.usage ?? 0) - (b.memory?.usage ?? 0); - break; - case 'disk': { - const aDisk = a.disks?.reduce((sum: number, d: { usage?: number }) => sum + (d.usage ?? 0), 0) ?? 0; - const bDisk = b.disks?.reduce((sum: number, d: { usage?: number }) => sum + (d.usage ?? 0), 0) ?? 0; - comparison = aDisk - bDisk; - break; - } - case 'uptime': - comparison = (a.uptimeSeconds ?? 0) - (b.uptimeSeconds ?? 0); - break; - default: - comparison = 0; - } - return direction === 'asc' ? comparison : -comparison; - }); - }); - - const matchesSearch = (host: Host) => { - const term = search().toLowerCase(); - if (!term) return true; - const hostname = (host.hostname || '').toLowerCase(); - const displayName = (host.displayName || '').toLowerCase(); - const platform = (host.platform || '').toLowerCase(); - const osName = (host.osName || '').toLowerCase(); - return ( - hostname.includes(term) || - displayName.includes(term) || - platform.includes(term) || - osName.includes(term) - ); - }; - - const matchesStatus = (host: Host) => { - const filter = statusFilter(); - if (filter === 'all') return true; - const normalized = (host.status || '').toLowerCase(); - return normalized === filter; - }; - - const filteredHosts = createMemo(() => { - return sortedHosts().filter((host) => matchesSearch(host) && matchesStatus(host)); - }); - - const getDiskStats = (host: Host) => { - if (!host.disks || host.disks.length === 0) return { percent: 0, used: 0, total: 0 }; - const totalUsed = host.disks.reduce((sum, d) => sum + (d.used ?? 0), 0); - const totalSize = host.disks.reduce((sum, d) => sum + (d.total ?? 0), 0); - return { - percent: totalSize > 0 ? (totalUsed / totalSize) * 100 : 0, - used: totalUsed, - total: totalSize - }; - }; - - - - const thClass = "px-1.5 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; - - return ( -
- - - - - - - } - title="Loading host data..." - description="Connecting to the monitoring service." - /> - - - - {/* Disconnected State */} - - - - - - } - title="Connection lost" - description={ - reconnecting() - ? 'Attempting to reconnect…' - : 'Unable to connect to the backend server' - } - tone="danger" - actions={ - !reconnecting() ? ( - - ) : undefined - } - /> - - - - - - {/* Filters with column visibility - hidden in kiosk mode */} - - setSearch('')} - searchInputRef={(el) => (searchInputRef = el)} - availableColumns={columnVisibility.availableToggles()} - isColumnHidden={columnVisibility.isHiddenByUser} - onColumnToggle={columnVisibility.toggle} - onColumnReset={columnVisibility.resetToDefaults} - /> - - - {/* Host Table */} - 0} - fallback={ - - - - } - > - -
- - - - - {/* Essential columns */} - - - - - - - - - - - - - - - {/* Secondary columns */} - - - - - - - - - - - {/* Supplementary columns */} - - - - - {/* Detailed columns */} - - - - - - - - - - - - - - {(host) => ( - toggleHostExpand(host.id)} - totalColumns={visibleColumnIds().length} - /> - )} - - -
handleSort('name')}> - Host {renderSortIndicator('name')} - handleSort('platform')}> - Platform {renderSortIndicator('platform')} - handleSort('cpu')}> - CPU {renderSortIndicator('cpu')} - handleSort('memory')}> - Memory {renderSortIndicator('memory')} - handleSort('disk')}> - Disk {renderSortIndicator('disk')} - - {HOST_COLUMNS.find(c => c.id === 'temp')?.icon} - handleSort('uptime')} title="Uptime"> - - {HOST_COLUMNS.find(c => c.id === 'uptime')?.icon} - {renderSortIndicator('uptime')} - - Agent - {HOST_COLUMNS.find(c => c.id === 'ip')?.icon} - ArchKernelRAID
-
-
-
- - } - > - - - - - } - title="No hosts reporting" - description="Install the Pulse host agent on Linux, macOS, or Windows machines to begin monitoring." - actions={ - - } - /> - -
-
-
- ); -}; - -// Individual host row component -interface HostRowProps { - host: Host; - isColVisible: (colId: string) => boolean; - isMobile: () => boolean; - getDiskStats: (host: Host) => { percent: number; used: number; total: number }; - customUrl?: string; - onUpdateCustomUrl: (hostId: string, url: string) => Promise; - onDeleteCustomUrl: (hostId: string) => Promise; - isExpanded: boolean; - onToggleExpand: () => void; - totalColumns: number; -} - -const HostRow: Component = (props) => { - // NOTE: Do NOT destructure props.host - it breaks SolidJS reactivity! - // Always access props.host directly to ensure updates flow through. - - - - - - - // Reactive getters to ensure values update when WebSocket data changes - const hostStatus = createMemo(() => getHostStatusIndicator(props.host)); - const isOnline = () => props.host.status === 'online'; - const cpuPercent = () => props.host.cpuUsage ?? 0; - const diskStats = () => props.getDiskStats(props.host); - - const rowClass = () => { - const base = 'transition-all duration-200 cursor-pointer'; - const hover = 'hover:bg-gray-50 dark:hover:bg-gray-800/50'; - const offline = !isOnline() ? 'opacity-60' : ''; - const expanded = props.isExpanded ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''; - return `${base} ${hover} ${offline} ${expanded}`; - }; - - return ( - <> - - {/* Host Name - always visible */} - -
-
- - - -
- -
-
-

- {props.host.displayName || props.host.hostname || props.host.id} -

- -

- {props.host.hostname} -

-
- -

- Updated {formatRelativeTime(props.host.lastSeen!)} -

-
-
- {/* Custom URL link */} - - event.stopPropagation()} - > - - - - - - {/* Edit URL button - shows on hover */} - - -
-
- - - {/* Platform */} - - -
-

{props.host.platform || '—'}

- -

- {props.host.osName} {props.host.osVersion} -

-
-
- -
- - {/* CPU */} - - -
}> - -
- - - - {/* Memory */} - - -
}> - - - - - - {/* Disk */} - - - }> - - - - - - {/* Temperature - shows primary temp with all sensors in tooltip */} - - -
- -
- -
- - {/* Uptime */} - - -
- —}> - - {formatUptime(props.host.uptimeSeconds!)} - - -
- -
- - {/* Agent Version */} - - -
- —}> - - {props.host.agentVersion} - - -
- -
- - {/* IP Address - uses icon + count with tooltip */} - - -
- -
- -
- - {/* Architecture */} - - -
- —}> - - {props.host.architecture} - - -
- -
- - {/* Kernel */} - - -
- —}> - - {props.host.kernelVersion} - - -
- -
- - {/* RAID Status */} - - -
- -
- -
- - - {/* URL editing popover - using shared component */} - - - {/* Drawer Row */} - - - -
e.stopPropagation()}> - { - // Optimistic update via the parent handler, which calls the metadata API - // We don't need to await here as the UI will update from the prop change - props.onUpdateCustomUrl(hostId, url); - }} - /> -
- - -
- - ); -}; diff --git a/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts b/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts deleted file mode 100644 index b7a9f6da7..000000000 --- a/frontend-modern/src/components/Hosts/__tests__/RAIDStatus.test.ts +++ /dev/null @@ -1,478 +0,0 @@ -/** - * Tests for RAID status display logic - * - * These tests cover the RAID status analysis and display logic used in HostsOverview. - */ -import { describe, expect, it } from 'vitest'; - -// Mock types matching HostRAIDArray from api.ts -interface HostRAIDDevice { - device: string; - state: string; - slot: number; -} - -interface HostRAIDArray { - device: string; - name?: string; - level: string; - state: string; - totalDevices: number; - activeDevices: number; - workingDevices: number; - failedDevices: number; - spareDevices: number; - uuid?: string; - devices: HostRAIDDevice[]; - rebuildPercent: number; - rebuildSpeed?: string; -} - -// Status analysis logic matching HostRAIDStatusCell -type RAIDStatusType = 'none' | 'ok' | 'degraded' | 'rebuilding'; - -interface RAIDStatus { - type: RAIDStatusType; - label: string; - color: string; -} - -function analyzeRAIDStatus(raid: HostRAIDArray[] | undefined): RAIDStatus { - if (!raid || raid.length === 0) { - return { type: 'none', label: '-', color: 'text-gray-400' }; - } - - let hasDegraded = false; - let hasRebuilding = false; - let maxRebuildPercent = 0; - - for (const array of raid) { - const state = array.state.toLowerCase(); - if (state.includes('degraded') || array.failedDevices > 0) { - hasDegraded = true; - } - if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) { - hasRebuilding = true; - maxRebuildPercent = Math.max(maxRebuildPercent, array.rebuildPercent); - } - } - - if (hasDegraded) { - return { type: 'degraded', label: 'Degraded', color: 'text-red-600 dark:text-red-400' }; - } - if (hasRebuilding) { - return { - type: 'rebuilding', - label: `${Math.round(maxRebuildPercent)}%`, - color: 'text-amber-600 dark:text-amber-400' - }; - } - return { type: 'ok', label: 'OK', color: 'text-green-600 dark:text-green-400' }; -} - -function getDeviceStateColor(state: string): string { - const s = state.toLowerCase(); - if (s.includes('active') || s.includes('sync')) return 'text-green-400'; - if (s.includes('spare')) return 'text-blue-400'; - if (s.includes('faulty') || s.includes('removed')) return 'text-red-400'; - if (s.includes('rebuilding')) return 'text-amber-400'; - return 'text-gray-400'; -} - -function getArrayStateColor(array: HostRAIDArray): string { - const state = array.state.toLowerCase(); - if (state.includes('degraded') || array.failedDevices > 0) return 'text-red-400'; - if (state.includes('recover') || state.includes('resync') || array.rebuildPercent > 0) return 'text-amber-400'; - if (state.includes('clean') || state.includes('active')) return 'text-green-400'; - return 'text-gray-400'; -} - -describe('RAID Status Analysis', () => { - describe('analyzeRAIDStatus', () => { - it('returns none status for undefined raid', () => { - const status = analyzeRAIDStatus(undefined); - expect(status.type).toBe('none'); - expect(status.label).toBe('-'); - }); - - it('returns none status for empty raid array', () => { - const status = analyzeRAIDStatus([]); - expect(status.type).toBe('none'); - }); - - it('returns ok status for healthy RAID1', () => { - const raid: HostRAIDArray[] = [{ - device: '/dev/md0', - level: 'raid1', - state: 'clean, active', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [ - { device: 'sda1', state: 'active sync', slot: 0 }, - { device: 'sdb1', state: 'active sync', slot: 1 }, - ], - rebuildPercent: 0, - }]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('ok'); - expect(status.label).toBe('OK'); - expect(status.color).toContain('green'); - }); - - it('returns degraded status when array has failed devices', () => { - const raid: HostRAIDArray[] = [{ - device: '/dev/md0', - level: 'raid1', - state: 'clean, degraded', - totalDevices: 2, - activeDevices: 1, - workingDevices: 1, - failedDevices: 1, - spareDevices: 0, - devices: [ - { device: 'sda1', state: 'active sync', slot: 0 }, - { device: 'sdb1', state: 'faulty', slot: 1 }, - ], - rebuildPercent: 0, - }]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('degraded'); - expect(status.label).toBe('Degraded'); - expect(status.color).toContain('red'); - }); - - it('returns degraded status when state includes degraded', () => { - const raid: HostRAIDArray[] = [{ - device: '/dev/md0', - level: 'raid5', - state: 'degraded, recovering', - totalDevices: 3, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, // No failed but degraded state - spareDevices: 0, - devices: [], - rebuildPercent: 50, - }]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('degraded'); - }); - - it('returns rebuilding status with percentage', () => { - const raid: HostRAIDArray[] = [{ - device: '/dev/md0', - level: 'raid1', - state: 'clean, recovering', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 45.5, - rebuildSpeed: '100MB/s', - }]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('rebuilding'); - expect(status.label).toBe('46%'); // Rounded - expect(status.color).toContain('amber'); - }); - - it('returns max rebuild percentage when multiple arrays rebuilding', () => { - const raid: HostRAIDArray[] = [ - { - device: '/dev/md0', - level: 'raid1', - state: 'recovering', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 30, - }, - { - device: '/dev/md1', - level: 'raid1', - state: 'recovering', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 75, - }, - ]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('rebuilding'); - expect(status.label).toBe('75%'); - }); - - it('prioritizes degraded over rebuilding', () => { - const raid: HostRAIDArray[] = [ - { - device: '/dev/md0', - level: 'raid1', - state: 'clean, degraded, recovering', - totalDevices: 2, - activeDevices: 1, - workingDevices: 1, - failedDevices: 1, // Failed device - spareDevices: 0, - devices: [], - rebuildPercent: 50, // Also rebuilding - }, - ]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('degraded'); // Degraded takes priority - }); - - it('handles resync state as rebuilding', () => { - const raid: HostRAIDArray[] = [{ - device: '/dev/md0', - level: 'raid1', - state: 'clean, resyncing', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 0, // resync state triggers rebuilding even without percent - }]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('rebuilding'); - }); - - it('handles multiple healthy arrays', () => { - const raid: HostRAIDArray[] = [ - { - device: '/dev/md0', - level: 'raid1', - state: 'clean', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 0, - }, - { - device: '/dev/md1', - level: 'raid5', - state: 'active', - totalDevices: 3, - activeDevices: 3, - workingDevices: 3, - failedDevices: 0, - spareDevices: 1, - devices: [], - rebuildPercent: 0, - }, - ]; - - const status = analyzeRAIDStatus(raid); - expect(status.type).toBe('ok'); - }); - }); - - describe('getDeviceStateColor', () => { - it('returns green for active devices', () => { - expect(getDeviceStateColor('active sync')).toContain('green'); - expect(getDeviceStateColor('Active Sync')).toContain('green'); - }); - - it('returns blue for spare devices', () => { - expect(getDeviceStateColor('spare')).toContain('blue'); - expect(getDeviceStateColor('hot spare')).toContain('blue'); - }); - - it('returns red for faulty devices', () => { - expect(getDeviceStateColor('faulty')).toContain('red'); - expect(getDeviceStateColor('removed')).toContain('red'); - }); - - it('returns amber for rebuilding devices', () => { - expect(getDeviceStateColor('rebuilding')).toContain('amber'); - }); - - it('returns gray for unknown states', () => { - expect(getDeviceStateColor('')).toContain('gray'); - expect(getDeviceStateColor('unknown')).toContain('gray'); - }); - }); - - describe('getArrayStateColor', () => { - it('returns green for clean/active arrays', () => { - const cleanArray: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'clean', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 0, - }; - expect(getArrayStateColor(cleanArray)).toContain('green'); - - const activeArray: HostRAIDArray = { ...cleanArray, state: 'active' }; - expect(getArrayStateColor(activeArray)).toContain('green'); - }); - - it('returns red for degraded arrays', () => { - const degradedArray: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'degraded', - totalDevices: 2, - activeDevices: 1, - workingDevices: 1, - failedDevices: 1, - spareDevices: 0, - devices: [], - rebuildPercent: 0, - }; - expect(getArrayStateColor(degradedArray)).toContain('red'); - }); - - it('returns red for arrays with failed devices even if state is clean', () => { - const failedDeviceArray: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'clean', // State says clean but... - totalDevices: 2, - activeDevices: 1, - workingDevices: 1, - failedDevices: 1, // Has a failed device - spareDevices: 0, - devices: [], - rebuildPercent: 0, - }; - expect(getArrayStateColor(failedDeviceArray)).toContain('red'); - }); - - it('returns amber for recovering/resyncing arrays', () => { - const recoveringArray: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'recovering', - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 50, - }; - expect(getArrayStateColor(recoveringArray)).toContain('amber'); - - const resyncArray: HostRAIDArray = { ...recoveringArray, state: 'resyncing' }; - expect(getArrayStateColor(resyncArray)).toContain('amber'); - }); - - it('returns amber for arrays with rebuild percent > 0', () => { - const rebuildingArray: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'clean', // State is clean but... - totalDevices: 2, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 25, // Rebuilding - }; - expect(getArrayStateColor(rebuildingArray)).toContain('amber'); - }); - }); -}); - -describe('RAID Level Support', () => { - const raidLevels = ['raid0', 'raid1', 'raid5', 'raid6', 'raid10']; - - it.each(raidLevels)('supports %s level', (level) => { - const array: HostRAIDArray = { - device: '/dev/md0', - level: level, - state: 'clean', - totalDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, - activeDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, - workingDevices: level === 'raid0' ? 2 : level === 'raid10' ? 4 : 3, - failedDevices: 0, - spareDevices: 0, - devices: [], - rebuildPercent: 0, - }; - - const status = analyzeRAIDStatus([array]); - expect(status.type).toBe('ok'); - }); -}); - -describe('RAID Device Counts', () => { - it('displays correct device counts for RAID1 with spares', () => { - const array: HostRAIDArray = { - device: '/dev/md0', - level: 'raid1', - state: 'clean', - totalDevices: 3, - activeDevices: 2, - workingDevices: 2, - failedDevices: 0, - spareDevices: 1, - devices: [ - { device: 'sda1', state: 'active sync', slot: 0 }, - { device: 'sdb1', state: 'active sync', slot: 1 }, - { device: 'sdc1', state: 'spare', slot: -1 }, - ], - rebuildPercent: 0, - }; - - expect(array.activeDevices).toBe(2); - expect(array.workingDevices).toBe(2); - expect(array.spareDevices).toBe(1); - expect(array.failedDevices).toBe(0); - expect(array.devices).toHaveLength(3); - }); - - it('displays degraded state for RAID5 missing one device', () => { - const array: HostRAIDArray = { - device: '/dev/md0', - level: 'raid5', - state: 'clean, degraded', - totalDevices: 4, - activeDevices: 3, - workingDevices: 3, - failedDevices: 1, - spareDevices: 0, - devices: [ - { device: 'sda1', state: 'active sync', slot: 0 }, - { device: 'sdb1', state: 'active sync', slot: 1 }, - { device: 'sdc1', state: 'active sync', slot: 2 }, - { device: 'sdd1', state: 'removed', slot: 3 }, - ], - rebuildPercent: 0, - }; - - const status = analyzeRAIDStatus([array]); - expect(status.type).toBe('degraded'); - expect(array.failedDevices).toBe(1); - }); -}); diff --git a/frontend-modern/src/components/PMG/MailGateway.tsx b/frontend-modern/src/components/PMG/MailGateway.tsx index 131de1011..7b7c6a1a2 100644 --- a/frontend-modern/src/components/PMG/MailGateway.tsx +++ b/frontend-modern/src/components/PMG/MailGateway.tsx @@ -1,7 +1,6 @@ import { Component, Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; import { Portal } from 'solid-js/web'; import { useWebSocket } from '@/App'; -import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { formatRelativeTime, formatBytes } from '@/utils/format'; @@ -278,8 +277,6 @@ const MailGateway: Component = () => { return (
- - {/* Loading State */} diff --git a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx b/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx deleted file mode 100644 index 2400a1546..000000000 --- a/frontend-modern/src/components/Proxmox/ProxmoxSectionNav.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { Component } from 'solid-js'; -import { createMemo, For } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; -import { useWebSocket } from '@/App'; - -type ProxmoxSection = 'overview' | 'storage' | 'ceph' | 'replication' | 'backups' | 'mail'; - -interface ProxmoxSectionNavProps { - current: ProxmoxSection; - class?: string; -} - -const allSections: Array<{ - id: ProxmoxSection; - label: string; - path: string; -}> = [ - { - id: 'overview', - label: 'Overview', - path: '/proxmox/overview', - }, - { - id: 'storage', - label: 'Storage', - path: '/proxmox/storage', - }, - { - id: 'ceph', - label: 'Ceph', - path: '/proxmox/ceph', - }, - { - id: 'replication', - label: 'Replication', - path: '/proxmox/replication', - }, - { - id: 'mail', - label: 'Mail Gateway', - path: '/proxmox/mail', - }, - { - id: 'backups', - label: 'Backups', - path: '/proxmox/backups', - }, - ]; - -export const ProxmoxSectionNav: Component = (props) => { - const navigate = useNavigate(); - const { state } = useWebSocket(); - - // Only show tabs if the corresponding feature has data: - // - Mail Gateway: requires PMG instances - // - Ceph: requires Ceph clusters (from agent or Proxmox API) - // - Replication: requires replication jobs - const sections = createMemo(() => { - const hasPMG = state.pmg && state.pmg.length > 0; - const hasCeph = state.cephClusters && state.cephClusters.length > 0; - const hasReplication = state.replicationJobs && state.replicationJobs.length > 0; - return allSections.filter((section) => - section.id === props.current || ( - (section.id !== 'mail' || hasPMG) && - (section.id !== 'ceph' || hasCeph) && - (section.id !== 'replication' || hasReplication) - ) - ); - }); - - const baseClasses = - 'inline-flex items-center px-1.5 sm:px-3 py-1 text-[11px] sm:text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900'; - - return ( -
- {(section) => { - const isActive = section.id === props.current; - const classes = isActive - ? `${baseClasses} text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400` - : `${baseClasses} hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50`; - - return ( - - ); - }} -
- ); -}; diff --git a/frontend-modern/src/components/Replication/Replication.tsx b/frontend-modern/src/components/Replication/Replication.tsx index c6985f2e7..07da1f11b 100644 --- a/frontend-modern/src/components/Replication/Replication.tsx +++ b/frontend-modern/src/components/Replication/Replication.tsx @@ -1,7 +1,6 @@ import type { Component } from 'solid-js'; import { Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; import { Portal } from 'solid-js/web'; -import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; @@ -220,7 +219,6 @@ const Replication: Component = () => { return (
- {/* Loading State */} diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 6dc85f345..0d56823a3 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -15,7 +15,6 @@ import { EnhancedStorageBar } from './EnhancedStorageBar'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; -import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { getNodeDisplayName } from '@/utils/nodes'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; @@ -681,8 +680,6 @@ const Storage: Component = () => { return (
- - {/* Node Selector */} { onNodeSelect={handleNodeSelect} filteredStorage={sortedStorage()} searchTerm={searchTerm()} + showNodeSummary={false} /> = (props) => { @@ -97,21 +98,24 @@ export const UnifiedNodeSelector: Component = (props) // Parent components now handle conditional rendering, so we can render directly const nodes = createMemo(() => props.nodes || state.nodes || []); + const showNodeSummary = () => props.showNodeSummary ?? true; return ( -
- -
+ +
+ +
+
); }; diff --git a/frontend-modern/src/pages/Ceph.tsx b/frontend-modern/src/pages/Ceph.tsx index 888afcb4d..25b7ecd8e 100644 --- a/frontend-modern/src/pages/Ceph.tsx +++ b/frontend-modern/src/pages/Ceph.tsx @@ -1,7 +1,6 @@ import { Component, For, Show, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; import { Portal } from 'solid-js/web'; import { useWebSocket } from '@/App'; -import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import type { CephPool, CephServiceStatus } from '@/types/api'; @@ -340,7 +339,6 @@ const Ceph: Component = () => { return (
{/* Navigation */} - {/* Loading State */}