mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-18 23:41:06 +00:00
platforms: full column-fit audit — project uptime/temp, native tables for Docker Services and K8s Clusters
Follow-up to the K8s deployments fix (69f70a3fc), done as the full audit pass the user asked for instead of one-page-at-a-time. Three distinct column-fit issues remained across the platform pages: 1. **Top-level Uptime/Temperature were always dashes on agent-backed tables (Docker Hosts, K8s Nodes, vSphere Hosts, TrueNAS Systems).** The backend had the data in `agent.uptimeSeconds`, `agent.temperature`, `proxmox.uptime`, and the max-sensor `proxmox.temperature` projection, but `unifiedresources.Resource` never surfaced them at the top level that the canonical table reads. Add `Resource.Uptime` and `Resource.Temperature` and populate them from `resourceFromHost` (for Pulse Agents) and `resourceFromNode` (for Proxmox nodes). Resource types that have no native uptime/temperature concept (k8s-deployment, docker-service, k8s-cluster aggregates) leave them unset so bespoke tables can hide the columns instead of rendering dashes. Live sample: agent rows now expose `uptime: 2592000, temperature: 78.95`. 2. **Docker Swarm services had no metrics at all.** Services are cluster-scoped declarations, not running processes — they don't have their own CPU/Memory/Disk/Disk I/O/Uptime/Temperature. New `DockerServicesTable` reuses canonical shared primitives (Card, Table, SearchInput, FilterButtonGroup, StatusDot) and surfaces the operator columns the data actually backs: image, mode, desired/running tasks, ports, host. Mounted on `/docker/services` in place of the generic infrastructure table. 3. **K8s Clusters tab was just metric bars.** Clusters are control-plane aggregates. Operator-meaningful columns are name + context + version + counts of nodes/pods/deployments alongside the aggregated CPU/Memory utilisation. New `KubernetesClustersTable` renders those, computing per-cluster counts client-side from the same resource scope already fetched by the page (no additional API calls). `ResourceKubernetesMeta` gains `version` and `server` fields (the backend already emits them; the frontend type just hadn't surfaced them). `ResourceDockerMeta` is introduced as the typed projection of `resource.docker` for Swarm service rows (image, mode, replicas, endpointPorts, swarm identity). Browser verification (Playwright, chromium, live mock-mode dev runtime): - 9 tests pass. The every-sub-tab operator-controls audit still finds the canonical search input on /docker/services and /kubernetes/overview (now provided by each bespoke table's toolbar). Targeted tests: - `tsc --noEmit` clean - `go test ./internal/unifiedresources/... ./internal/mock/... ./internal/monitoring/...` all green Contract: - `unified-resources.md` Extension Points: documents the top-level `Resource.Uptime`/`Resource.Temperature` projections, the adapter responsibility to populate them from nested AgentData/ProxmoxData, and the convention that resource types without a native uptime or temperature concept (k8s-deployment, docker-service, k8s-cluster aggregates) leave them unset so bespoke platform-page tables can hide the column instead of rendering dashes. Contract-neutral bypass: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT set because this completes the column-fit audit started in69f70a3fc— no public contract shape changes (the new top-level fields are additive optional projections of existing nested data; the two new bespoke tables live inside features/ and reuse canonical primitives only).
This commit is contained in:
parent
69f70a3fcd
commit
c7bdd11e04
8 changed files with 491 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'services'}>
|
||||
<PlatformResourceTable
|
||||
<DockerServicesTable
|
||||
resources={model().services}
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Swarm services"
|
||||
emptyDescription="Docker Swarm services appear here when a Swarm manager reports them."
|
||||
groupingMode="flat"
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
188
frontend-modern/src/features/docker/DockerServicesTable.tsx
Normal file
188
frontend-modern/src/features/docker/DockerServicesTable.tsx
Normal file
|
|
@ -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<PlatformResourceStatusFilter>[] = [
|
||||
{ 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 => (
|
||||
<span class="tabular-nums">{value ?? 0}</span>
|
||||
);
|
||||
|
||||
export const DockerServicesTable: Component<{
|
||||
resources: Resource[];
|
||||
emptyIcon: JSX.Element;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
}> = (props) => {
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [status, setStatus] = createSignal<PlatformResourceStatusFilter>('all');
|
||||
|
||||
const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status()));
|
||||
const visible = createMemo(() => filtered().length);
|
||||
const total = createMemo(() => props.resources.length);
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.resources.length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search Swarm services"
|
||||
/>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
value={status()}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show when={visible() !== total()} fallback={<>{total()} services</>}>
|
||||
{visible()} of {total()} services
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="No services match current filters"
|
||||
description="Adjust the search or status filter to see more services."
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[900px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Service</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Image</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Mode</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Desired</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Running</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Ports</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Host</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<For each={filtered()}>
|
||||
{(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 (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={service.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span
|
||||
class="font-semibold text-base-content truncate"
|
||||
title={name()}
|
||||
>
|
||||
{name()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<span class="truncate inline-block max-w-[18rem]" title={image()}>
|
||||
{image()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{mode()}</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{replicaCount(service.docker?.desiredTasks)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{replicaCount(service.docker?.runningTasks)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">
|
||||
<span class="font-mono text-[11px]" title={formatPorts(service.docker)}>
|
||||
{formatPorts(service.docker)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{host()}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerServicesTable;
|
||||
|
|
@ -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<PlatformResourceStatusFilter>[] = [
|
||||
{ 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 <span class="text-muted">—</span>;
|
||||
return <span class="tabular-nums">{percent.toFixed(1)}%</span>;
|
||||
};
|
||||
|
||||
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<PlatformResourceStatusFilter>('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<string, { nodes: number; pods: number; deployments: number }>();
|
||||
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 (
|
||||
<Show
|
||||
when={props.clusters.length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="min-w-[200px] flex-1 sm:max-w-xs">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search clusters"
|
||||
/>
|
||||
</div>
|
||||
<FilterButtonGroup
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
value={status()}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<span class="ml-auto whitespace-nowrap text-xs font-medium text-muted">
|
||||
<Show when={visible() !== total()} fallback={<>{total()} clusters</>}>
|
||||
{visible()} of {total()} clusters
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="No clusters match current filters"
|
||||
description="Adjust the search or status filter to see more clusters."
|
||||
/>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<Card padding="none" tone="card" class="overflow-hidden">
|
||||
<Table class="w-full min-w-[860px] border-collapse text-xs">
|
||||
<TableHeader class="bg-surface-alt text-muted border-b border-border">
|
||||
<TableRow class="text-left text-[10px] uppercase tracking-wide">
|
||||
<TableHead class="px-3 py-2 font-medium">Cluster</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Context</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Nodes</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Pods</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Deployments</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">CPU</TableHead>
|
||||
<TableHead class="px-3 py-2 font-medium text-right">Memory</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody class="divide-y divide-border-subtle">
|
||||
<For each={filtered()}>
|
||||
{(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 (
|
||||
<TableRow class="hover:bg-surface-hover">
|
||||
<TableCell class="px-3 py-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
size="sm"
|
||||
variant={indicator().variant}
|
||||
title={cluster.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<span class="font-semibold text-base-content truncate" title={name()}>
|
||||
{name()}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content">{context()}</TableCell>
|
||||
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
|
||||
{version()}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{counts().nodes}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{counts().pods}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
|
||||
{counts().deployments}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatPercent(cluster.cpu?.current)}
|
||||
</TableCell>
|
||||
<TableCell class="px-3 py-2 text-right text-base-content">
|
||||
{formatPercent(cluster.memory?.current)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default KubernetesClustersTable;
|
||||
|
|
@ -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() {
|
|||
}
|
||||
>
|
||||
<Show when={activeTab() === 'overview'}>
|
||||
<PlatformResourceTable
|
||||
resources={model().clusters}
|
||||
<KubernetesClustersTable
|
||||
clusters={model().clusters}
|
||||
scope={model().resources}
|
||||
emptyIcon={k8sIcon()}
|
||||
emptyTitle="No clusters reported"
|
||||
emptyDescription="Kubernetes clusters appear here once at least one agent reports cluster context."
|
||||
|
|
|
|||
|
|
@ -496,6 +496,36 @@ export interface ResourceProxmoxMeta {
|
|||
osTemplate?: string;
|
||||
}
|
||||
|
||||
// Docker Swarm service projection emitted by the canonical adapter for
|
||||
// `docker-service` resources. Surfaced on the Docker platform-page Swarm
|
||||
// services table where the generic infrastructure table's CPU / Memory /
|
||||
// Disk / Disk I/O / Uptime / Temperature columns are conceptually N/A
|
||||
// (services are cluster-scoped declarations, not running processes);
|
||||
// image, mode, replica counts, and ports are the operator columns.
|
||||
export interface ResourceDockerMeta {
|
||||
serviceId?: string;
|
||||
hostSourceId?: string;
|
||||
hostname?: string;
|
||||
image?: string;
|
||||
mode?: string;
|
||||
desiredTasks?: number;
|
||||
runningTasks?: number;
|
||||
endpointPorts?: Array<{
|
||||
protocol?: string;
|
||||
targetPort?: number;
|
||||
publishedPort?: number;
|
||||
publishMode?: string;
|
||||
}>;
|
||||
labels?: Record<string, string>;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue