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"`