diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 4a5cb2c1c..046a10206 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -271,7 +271,20 @@ payloads may expose a camelCase transport projection, but the counts must be derived from `internal/unifiedresources/policy_posture.go` after canonical policy metadata has been refreshed, not recomputed from frontend labels, AI-only summary payloads, or page-local heuristics. -4. Add metrics-target normalization or synthetic metrics support through `internal/unifiedresources/metrics_targets.go` and `internal/unifiedresources/metrics.go`. +4. Add metrics-target normalization, surface-friendly projections of + nested source payloads, or synthetic metrics support through + `internal/unifiedresources/metrics_targets.go`, + `internal/unifiedresources/metrics.go`, and the relevant adapter in + `internal/unifiedresources/adapters.go`. The unified `Resource` shape + carries top-level `Uptime` and `Temperature` projections so frontend + tables that render those columns do not have to dig into per-source + payloads (`agent.uptimeSeconds`, `proxmox.uptime`, + `agent.temperature`, `proxmox.temperature`); adapters that wrap an + `AgentData` or `ProxmoxData` must populate those top-level fields + from the nested source values, and adapters for resource types that + have no native uptime/temperature concept (e.g. `k8s-deployment`, + `docker-service`, `k8s-cluster` aggregates) must leave them unset so + bespoke platform-page tables can hide the column entirely. Kubernetes deployment metrics live on the canonical adapter through `metricsFromKubernetesDeployment(cluster, deployment)`. Upstream Deployments do not expose CPU / memory natively because they are diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 2cca7dd7c..3ea3567f8 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -9,6 +9,7 @@ import { PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; +import { DockerServicesTable } from './DockerServicesTable'; import { DOCKER_TAB_SPECS, buildDockerPageModel, @@ -94,12 +95,11 @@ export function DockerPageSurface() { /> - diff --git a/frontend-modern/src/features/docker/DockerServicesTable.tsx b/frontend-modern/src/features/docker/DockerServicesTable.tsx new file mode 100644 index 000000000..ff65f7af9 --- /dev/null +++ b/frontend-modern/src/features/docker/DockerServicesTable.tsx @@ -0,0 +1,188 @@ +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 Swarm services are cluster-scoped declarations, not running +// processes — they have no CPU / Memory / Disk / Disk I/O / Uptime / +// Temperature of their own (those metrics live on the controlled tasks +// and the underlying nodes). The canonical infrastructure table renders +// dashes for those columns on docker-service rows. This service-native +// table reuses canonical shared primitives (Card, Table, SearchInput, +// FilterButtonGroup, StatusDot) but surfaces operator columns that the +// data actually backs: image, mode, replica counts, ports, host. + +const STATUS_FILTER_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All' }, + { value: 'online', label: 'Healthy' }, + { value: 'degraded', label: 'Degraded' }, + { value: 'offline', label: 'Offline' }, +]; + +const formatPorts = (ports: Resource['docker'] extends infer T ? T : never): string => { + const entries = (ports as { endpointPorts?: Array<{ publishedPort?: number; targetPort?: number; protocol?: string }> })?.endpointPorts ?? []; + if (entries.length === 0) return '—'; + return entries + .map((entry) => { + const protocol = entry?.protocol ? `/${entry.protocol.toLowerCase()}` : ''; + if (entry?.publishedPort && entry?.targetPort) { + return `${entry.publishedPort}:${entry.targetPort}${protocol}`; + } + const single = entry?.publishedPort ?? entry?.targetPort; + return single ? `${single}${protocol}` : ''; + }) + .filter((part) => part.length > 0) + .join(', ') || '—'; +}; + +const replicaCount = (value: number | undefined): JSX.Element => ( + {value ?? 0} +); + +export const DockerServicesTable: 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()} services}> + {visible()} of {total()} services + + +
+ + 0} + fallback={ + + + + } + > + + + + + Service + Image + Mode + Desired + Running + Ports + Host + + + + + {(service) => { + const name = () => asTrimmedString(service.name) || service.id; + const image = () => asTrimmedString(service.docker?.image) || '—'; + const mode = () => asTrimmedString(service.docker?.mode) || '—'; + const host = () => asTrimmedString(service.docker?.hostname) || '—'; + const indicator = () => getSimpleStatusIndicator(service.status); + return ( + + +
+ + + {name()} + +
+
+ + + {image()} + + + {mode()} + + {replicaCount(service.docker?.desiredTasks)} + + + {replicaCount(service.docker?.runningTasks)} + + + + {formatPorts(service.docker)} + + + {host()} +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default DockerServicesTable; diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx new file mode 100644 index 000000000..8b72b817c --- /dev/null +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -0,0 +1,216 @@ +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'; + +// Kubernetes clusters are control-plane aggregates, not single processes — +// they have no per-cluster Disk I/O / Uptime / Temperature concepts that +// the generic infrastructure table would render. The cluster row already +// carries aggregated CPU / Memory through `metricsFromKubernetesCluster`, +// but the operator columns that matter for "where do my clusters stand at +// a glance" are name + context + version + counts of nodes, pods, and +// deployments. This bespoke table surfaces those alongside the canonical +// CPU/Memory utilisation. It reuses the same shared primitives every +// other platform-page table uses. + +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)}%; +}; + +export const KubernetesClustersTable: Component<{ + clusters: Resource[]; + // All Kubernetes-tagged resources from the same query, so the table can + // count nodes/pods/deployments per cluster client-side without firing + // additional requests. + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; +}> = (props) => { + const [search, setSearch] = createSignal(''); + const [status, setStatus] = createSignal('all'); + + const filtered = createMemo(() => filterPlatformResources(props.clusters, search(), status())); + const visible = createMemo(() => filtered().length); + const total = createMemo(() => props.clusters.length); + + const countsByCluster = createMemo(() => { + const map = new Map(); + for (const cluster of props.clusters) { + map.set(cluster.id, { nodes: 0, pods: 0, deployments: 0 }); + } + for (const resource of props.scope) { + const clusterId = + asTrimmedString(resource.kubernetes?.clusterId) || + asTrimmedString(resource.kubernetes?.clusterName); + if (!clusterId) continue; + // Match against the cluster row by clusterId. The cluster row's + // own canonical id differs from its `kubernetes.clusterId`, so map + // by the kubernetes-side identifier first, then fall back to row id. + let bucket = null as { nodes: number; pods: number; deployments: number } | null; + for (const cluster of props.clusters) { + const k = cluster.kubernetes; + if (!k) continue; + if ( + asTrimmedString(k.clusterId) === clusterId || + asTrimmedString(k.clusterName) === clusterId + ) { + bucket = map.get(cluster.id) ?? null; + break; + } + } + if (!bucket) continue; + if (resource.type === 'k8s-node') bucket.nodes += 1; + else if (resource.type === 'agent' && resource.sources?.includes('kubernetes')) bucket.nodes += 1; + else if (resource.type === 'pod') bucket.pods += 1; + else if (resource.type === 'k8s-deployment') bucket.deployments += 1; + } + // Fallback when scope-based counts come back empty (e.g. tests that + // only supply the cluster rows): keep the rendered counts honest at 0. + return map; + }); + + return ( + 0} + fallback={ + + + + } + > +
+
+
+ +
+ + + {total()} clusters}> + {visible()} of {total()} clusters + + +
+ + 0} + fallback={ + + + + } + > + + + + + Cluster + Context + Version + Nodes + Pods + Deployments + CPU + Memory + + + + + {(cluster) => { + const name = () => + asTrimmedString(cluster.kubernetes?.clusterName) || + asTrimmedString(cluster.name) || + cluster.id; + const context = () => asTrimmedString(cluster.kubernetes?.context) || '—'; + const version = () => asTrimmedString(cluster.kubernetes?.version) || '—'; + const counts = () => countsByCluster().get(cluster.id) ?? { nodes: 0, pods: 0, deployments: 0 }; + const indicator = () => getSimpleStatusIndicator(cluster.status); + return ( + + +
+ + + {name()} + +
+
+ {context()} + + {version()} + + + {counts().nodes} + + + {counts().pods} + + + {counts().deployments} + + + {formatPercent(cluster.cpu?.current)} + + + {formatPercent(cluster.memory?.current)} + +
+ ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default KubernetesClustersTable; diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index 42b6355b5..eb7df21a6 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -9,6 +9,7 @@ import { PlatformSectionTabs, PlatformTableEmptyState, } from '@/features/platformPage/sharedPlatformPage'; +import { KubernetesClustersTable } from './KubernetesClustersTable'; import { KubernetesDeploymentsTable } from './KubernetesDeploymentsTable'; import { KUBERNETES_TAB_SPECS, @@ -80,8 +81,9 @@ export function KubernetesPageSurface() { } > - ; + labels?: Record; + swarm?: { + clusterId?: string; + clusterName?: string; + nodeId?: string; + nodeRole?: string; + scope?: string; + }; +} + export interface ResourceKubernetesMetricCapabilities { nodeCpuMemory?: boolean; nodeTelemetry?: boolean; @@ -528,6 +558,10 @@ export interface ResourceKubernetesMeta { updatedReplicas?: number; readyReplicas?: number; availableReplicas?: number; + // Cluster-only fields surfaced on the Kubernetes platform-page + // Clusters table for at-a-glance fleet posture. + version?: string; + server?: string; } export interface ResourceVMwareMeta { @@ -663,6 +697,7 @@ export interface Resource { // Prefer these over casting `platformData` when available. agent?: ResourceAgentMeta; kubernetes?: ResourceKubernetesMeta; + docker?: ResourceDockerMeta; vmware?: ResourceVMwareMeta; proxmox?: ResourceProxmoxMeta; pbs?: ResourcePBSMeta; diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 57835ca99..601407558 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -68,15 +68,17 @@ func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resourc metrics := metricsFromProxmoxNode(node) resource := Resource{ - Type: ResourceTypeAgent, - Technology: "proxmox", - Name: name, - Status: statusFromString(node.Status), - LastSeen: node.LastSeen, - UpdatedAt: time.Now().UTC(), - Metrics: metrics, - Proxmox: proxmox, - Tags: nil, + Type: ResourceTypeAgent, + Technology: "proxmox", + Name: name, + Status: statusFromString(node.Status), + LastSeen: node.LastSeen, + UpdatedAt: time.Now().UTC(), + Metrics: metrics, + Uptime: node.Uptime, + Temperature: proxmox.Temperature, + Proxmox: proxmox, + Tags: nil, } return resource, identity @@ -412,15 +414,17 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { metrics := metricsFromHost(host) resource := Resource{ - Type: ResourceTypeAgent, - Technology: strings.TrimSpace(platform), - Name: name, - Status: storageStatus(statusFromString(host.Status), agent.StorageRisk), - LastSeen: host.LastSeen, - UpdatedAt: time.Now().UTC(), - Metrics: metrics, - Agent: agent, - Tags: host.Tags, + Type: ResourceTypeAgent, + Technology: strings.TrimSpace(platform), + Name: name, + Status: storageStatus(statusFromString(host.Status), agent.StorageRisk), + LastSeen: host.LastSeen, + UpdatedAt: time.Now().UTC(), + Metrics: metrics, + Uptime: host.UptimeSeconds, + Temperature: agent.Temperature, + Agent: agent, + Tags: host.Tags, } return resource, identity diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 83db083ca..db6c02fec 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -30,6 +30,16 @@ type Resource struct { Identity ResourceIdentity `json:"identity,omitempty"` Metrics *ResourceMetrics `json:"metrics,omitempty"` + // Surface-friendly projections of the nested source payloads that the + // frontend infrastructure table reads directly. Adapters that wrap an + // `AgentData` (Pulse-managed host) or `ProxmoxData` (Proxmox node) + // project the runtime uptime and the max-sensor temperature here so + // the canonical table renders real values instead of dashes for + // agent-backed rows. Resources that have no native uptime/temperature + // concept (e.g. k8s-deployment, docker-service) leave these unset. + Uptime int64 `json:"uptime,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + ParentID *string `json:"parentId,omitempty"` ParentName string `json:"parentName,omitempty"` ChildCount int `json:"childCount,omitempty"`