diff --git a/frontend-modern/src/features/docker/DockerHostsTable.tsx b/frontend-modern/src/features/docker/DockerHostsTable.tsx new file mode 100644 index 000000000..ba33235d7 --- /dev/null +++ b/frontend-modern/src/features/docker/DockerHostsTable.tsx @@ -0,0 +1,210 @@ +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; +import { SearchInput } from '@/components/shared/SearchInput'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { getSimpleStatusIndicator } from '@/utils/status'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { + filterPlatformResources, + type PlatformResourceStatusFilter, +} from '@/features/platformPage/sharedPlatformPage'; +import type { Resource } from '@/types/resource'; + +// Docker / Podman hosts are container hosts, not generic Pulse Agents. +// The operator columns that matter are runtime (Docker vs Podman), +// runtime version, container count, and Swarm role, alongside the +// usual CPU / Memory / Disk / Uptime / Temperature from the agent +// telemetry. The generic infrastructure table renders the metrics fine +// but omits the runtime context that distinguishes a Docker host from +// any other agent. This bespoke table reuses canonical shared +// primitives and surfaces the Docker-native columns. + +const STATUS_FILTER_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatPercent = (percent?: number): JSX.Element => { + if (typeof percent !== 'number' || Number.isNaN(percent)) return ; + return {percent.toFixed(1)}%; +}; + +const formatUptime = (seconds: number | undefined): string => { + if (!seconds || seconds <= 0) return '—'; + const days = Math.floor(seconds / 86_400); + if (days > 0) return `${days}d`; + const hours = Math.floor(seconds / 3_600); + if (hours > 0) return `${hours}h`; + const mins = Math.floor(seconds / 60); + return `${mins}m`; +}; + +const formatTemperature = (celsius: number | undefined): JSX.Element => { + if (typeof celsius !== 'number' || celsius <= 0) return ; + return {celsius.toFixed(1)}°C; +}; + +const runtimeLabel = (runtime: string | undefined): string => { + const normalized = (runtime || '').trim().toLowerCase(); + if (normalized === 'docker') return 'Docker'; + if (normalized === 'podman') return 'Podman'; + return runtime || '—'; +}; + +export const DockerHostsTable: Component<{ + resources: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.resources.length); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} hosts}> + {visible()} of {total()} hosts + + +
+ + 0} + fallback={ + + + + } + > + + + + + Host + Runtime + Version + Containers + CPU + Memory + Uptime + Temp + Swarm role + + + + + {(host) => { + const docker = () => host.docker as + | (NonNullable & { + runtime?: string; + runtimeVersion?: string; + containerCount?: number; + uptimeSeconds?: number; + temperature?: number; + swarm?: { nodeRole?: string }; + }) + | undefined; + const name = () => asTrimmedString(host.name) || host.id; + const runtime = () => runtimeLabel(docker()?.runtime); + const version = () => asTrimmedString(docker()?.runtimeVersion) || '—'; + const containerCount = () => docker()?.containerCount ?? 0; + const swarmRole = () => { + const role = asTrimmedString(docker()?.swarm?.nodeRole); + return role ? role.charAt(0).toUpperCase() + role.slice(1) : '—'; + }; + const indicator = () => getSimpleStatusIndicator(host.status); + return ( + + +
+ + + {name()} + +
+
+ {runtime()} + + {version()} + + + {containerCount()} + + + {formatPercent(host.cpu?.current)} + + + {formatPercent(host.memory?.current)} + + + {formatUptime(host.uptime ?? docker()?.uptimeSeconds)} + + + {formatTemperature(host.temperature ?? docker()?.temperature)} + + {swarmRole()} +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default DockerHostsTable; diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 3ea3567f8..fa51320a8 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -5,10 +5,10 @@ import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PlatformErrorState, - PlatformResourceTable, PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; +import { DockerHostsTable } from './DockerHostsTable'; import { DockerServicesTable } from './DockerServicesTable'; import { DOCKER_TAB_SPECS, @@ -74,7 +74,7 @@ export function DockerPageSurface() { } > - - [] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatPercent = (percent?: number): JSX.Element => { + if (typeof percent !== 'number' || Number.isNaN(percent)) return ; + return {percent.toFixed(1)}%; +}; + +const powerStateVariant = ( + state: string | undefined, +): 'success' | 'warning' | 'danger' | 'muted' => { + const normalized = (state || '').trim().toUpperCase(); + if (normalized === 'POWERED_ON') return 'success'; + if (normalized === 'POWERED_OFF') return 'muted'; + if (normalized === 'SUSPENDED') return 'warning'; + return 'muted'; +}; + +const formatPowerState = (state: string | undefined): string => { + const normalized = (state || '').trim(); + if (!normalized) return '—'; + return normalized + .split('_') + .map((part) => part.charAt(0) + part.slice(1).toLowerCase()) + .join(' '); +}; + +export const VsphereHostsTable: Component<{ + hosts: Resource[]; + // Full vSphere scope so we can count VMs per host without spawning + // additional fetches. + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.hosts, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.hosts.length); + + const vmCountByHost = createMemo(() => { + const map = new Map(); + for (const resource of props.scope) { + if (resource.type !== 'vm') continue; + const runtimeHost = asTrimmedString(resource.vmware?.runtimeHostId); + if (!runtimeHost) continue; + map.set(runtimeHost, (map.get(runtimeHost) ?? 0) + 1); + } + return map; + }); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} hosts}> + {visible()} of {total()} hosts + + +
+ + 0} + fallback={ + + + + } + > + + + + + Host + Datacenter + Cluster + Power + CPU + Memory + Datastores + VMs + vCenter + + + + + {(host) => { + const meta = () => host.vmware; + const name = () => asTrimmedString(host.name) || host.id; + const datacenter = () => asTrimmedString(meta()?.datacenterName) || '—'; + const cluster = () => asTrimmedString(meta()?.clusterName) || '—'; + const vcenter = () => asTrimmedString(meta()?.vcenterHost) || '—'; + const datastoreCount = () => meta()?.datastoreIds?.length ?? meta()?.datastoreNames?.length ?? 0; + const vmCount = () => + vmCountByHost().get(asTrimmedString(meta()?.managedObjectId) || '') ?? 0; + const indicator = () => getSimpleStatusIndicator(host.status); + return ( + + +
+ + + {name()} + +
+
+ {datacenter()} + {cluster()} + +
+ + {formatPowerState(meta()?.powerState)} +
+
+ + {formatPercent(host.cpu?.current)} + + + {formatPercent(host.memory?.current)} + + + {datastoreCount()} + + + {vmCount()} + + + + {vcenter()} + + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default VsphereHostsTable; diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 601407558..7d03c3b75 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1171,15 +1171,17 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) metrics := metricsFromDockerHost(host) resource := Resource{ - Type: ResourceTypeAgent, - Technology: strings.TrimSpace(host.Runtime), - Name: name, - Status: statusFromString(host.Status), - LastSeen: host.LastSeen, - UpdatedAt: time.Now().UTC(), - Metrics: metrics, - Docker: docker, - Tags: nil, + Type: ResourceTypeAgent, + Technology: strings.TrimSpace(host.Runtime), + Name: name, + Status: statusFromString(host.Status), + LastSeen: host.LastSeen, + UpdatedAt: time.Now().UTC(), + Metrics: metrics, + Uptime: host.UptimeSeconds, + Temperature: host.Temperature, + Docker: docker, + Tags: nil, } return resource, identity