mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Gate platform workflow tabs by inventory
Keep Overview as the stable platform landing tab, hide workflow tabs without current inventory or signal, and route legacy Docker containers URLs back to the host-plus-workloads overview.
This commit is contained in:
@@ -179,12 +179,21 @@ and Kubernetes platform pages may add native API-backed sections, but the tabs
|
||||
must use `PlatformSectionTabs`, canonical table alignment helpers, and shared
|
||||
resource type presentation/reporting helpers rather than page-local tab shells,
|
||||
alignment classes, or ad hoc report-category coercion. Platform tabs are
|
||||
workflow-level navigation, not one visible tab per API resource kind: Docker /
|
||||
Podman exposes `Overview`, `Containers`, `Images`, `Storage`, `Networks`, and
|
||||
`Swarm`, while Kubernetes exposes `Overview`, `Nodes`, `Workloads`, `Services`,
|
||||
`Storage`, `Configuration`, and `Events`. API-native tables remain bespoke under
|
||||
those workflows, so Docker `Storage` owns engine disk usage plus volumes and
|
||||
Docker `Swarm` owns services, tasks, nodes, secrets, and configs; Kubernetes
|
||||
workflow-level navigation, not one visible tab per API resource kind, and they
|
||||
must be evidence-gated by their owning row or signal model. `Overview` is the
|
||||
stable landing surface; supporting workflow tabs appear only when the current
|
||||
setup has native inventory or signal for that workflow, and legacy object URLs
|
||||
resolve to their owning workflow only when that workflow is visible. Docker /
|
||||
Podman may expose `Overview`, `Images`, `Storage`, `Networks`, and `Swarm`,
|
||||
while legacy `/docker/containers` resolves to the Overview landing surface
|
||||
rather than remaining a separate visible tab. Kubernetes may expose `Overview`,
|
||||
`Nodes`, `Workloads`, `Services`, `Storage`, `Configuration`, and `Events`;
|
||||
TrueNAS and vSphere follow the same evidence-gated primitive for native
|
||||
storage, service, app, VM, protection, datastore, network, health, and activity
|
||||
workflows. API-native tables remain bespoke under those workflows, so Docker
|
||||
`Overview` owns runtime hosts plus primary container workloads, Docker `Storage`
|
||||
owns engine disk usage plus volumes, and Docker `Swarm` owns services, tasks,
|
||||
nodes, secrets, and configs; Kubernetes
|
||||
`Workloads` owns Pods, Deployments, controllers, and autoscaling, `Services`
|
||||
owns Services plus ingress/endpoint inventory, and `Configuration` owns config
|
||||
plus policy inventory. Backup and recovery platform pages follow the same
|
||||
@@ -194,9 +203,11 @@ not grow one top-level tab for each API source merely because that source has a
|
||||
table. Legacy object-specific URLs may resolve to the owning
|
||||
workflow tab, but they must not reappear as top-level platform navigation unless
|
||||
the product IA is intentionally changed. Overview tabs must stay deliberately
|
||||
narrow instead of repeating every detail table: Docker / Podman Overview owns
|
||||
runtime hosts, while Kubernetes Overview owns cluster/control-plane rollup; the
|
||||
object tables live in their dedicated workflow tabs. Docker / Podman native subsections now
|
||||
shaped around the primary operator job instead of repeating every detail table:
|
||||
Docker / Podman Overview owns runtime hosts and primary container workloads in
|
||||
the proven host-then-workloads pattern, while Kubernetes Overview owns
|
||||
cluster/control-plane rollup; supporting object tables live in their dedicated
|
||||
workflow tabs. Docker / Podman native subsections now
|
||||
include runtime containers, engine storage usage, Swarm node inventory, and
|
||||
metadata-only Swarm secret/config inventory where the documented Docker APIs
|
||||
report those resources; Podman-only libpod pod inventory must not be represented
|
||||
|
||||
@@ -160,20 +160,32 @@ generic controller/event inventory table.
|
||||
|
||||
Container runtime navigation is a unified-resource consumer boundary: the
|
||||
`/docker` route is the Docker / Podman runtime lens, not an exclusive owning
|
||||
platform. Its subtabs may be shown only from canonical resource evidence
|
||||
(`app-container` for containers, `docker-service` for Swarm services), and
|
||||
inactive standalone Swarm metadata must not be interpreted as host-role or
|
||||
service-surface proof. The unified-resource adapter is the backend fail-closed
|
||||
layer for that rule, so persisted or older-agent inactive Swarm payloads cannot
|
||||
reintroduce false Swarm capability surfaces.
|
||||
platform. Overview is the primary Docker / Podman landing surface and carries
|
||||
runtime host rows plus primary `app-container` workload rows so small and
|
||||
medium estates preserve the proven Pulse host-then-workloads interaction. The
|
||||
legacy `/docker/containers` route is a compatibility alias for that Overview
|
||||
surface, not a separate visible tab. Supporting subtabs such as Images,
|
||||
Storage, Networks, and Swarm may be shown only from canonical resource
|
||||
evidence, and inactive standalone Swarm metadata must not be interpreted as
|
||||
host-role or service-surface proof. The unified-resource adapter is the backend
|
||||
fail-closed layer for that rule, so persisted or older-agent inactive Swarm
|
||||
payloads cannot reintroduce false Swarm capability surfaces.
|
||||
|
||||
Platform Overview tabs are rollup boundaries, not duplicate inventory dumps.
|
||||
Docker / Podman Overview owns runtime host rows only; container, image, storage,
|
||||
network, and Swarm object rows belong in their workflow tabs. Kubernetes
|
||||
Overview owns cluster/control-plane rollup rows only; node, workload, service,
|
||||
storage, configuration, policy, and event object rows belong in their workflow
|
||||
tabs. If a future Overview repeats a detailed table, the owning workflow must be
|
||||
retired or the Overview content must be reduced to aggregate signal.
|
||||
Docker / Podman Overview owns runtime host rows and primary container workload
|
||||
rows; image, storage, network, and Swarm object rows belong in their workflow
|
||||
tabs. Kubernetes Overview owns cluster/control-plane rollup rows only; node,
|
||||
workload, service, storage, configuration, policy, and event object rows belong
|
||||
in their workflow tabs. If a future Overview repeats a detailed table, the
|
||||
owning workflow must be retired or the Overview content must be reduced to
|
||||
aggregate signal.
|
||||
Across platform/runtime pages, workflow tabs are evidence-gated from the
|
||||
canonical model that owns their rows. `Overview` is the stable landing tab;
|
||||
supporting tabs appear only when their native inventory or signal exists, and
|
||||
legacy/direct object routes fall back to `Overview` when the requested workflow
|
||||
has no rows for the current setup. Signals outside unified-resource inventory,
|
||||
such as TrueNAS recovery protection points or vSphere activity timeline rows,
|
||||
must be treated as explicit tab evidence rather than permanent navigation.
|
||||
|
||||
Kubernetes configuration and policy inventory are unified-resource consumer
|
||||
boundaries: the `/kubernetes/configuration` workflow tab must render Namespace,
|
||||
@@ -302,8 +314,9 @@ proof can distinguish a populated disk-usage tab from an empty fixture.
|
||||
generic workload rows and Swarm services. Runtime containers must preserve
|
||||
Docker Engine container identity, owning host/runtime context, image,
|
||||
state/health/restart/update status, ports, networks, and mounts through
|
||||
`DockerData` so `/docker/containers` can use the native
|
||||
`DockerContainersTable` rather than `WorkloadsSurface`. Runtime image,
|
||||
`DockerData` so Docker Overview and its `/docker/containers` compatibility
|
||||
route can use the native `DockerContainersTable` rather than
|
||||
`WorkloadsSurface`. Runtime image,
|
||||
volume, network, task, Swarm node, Swarm secret, Swarm config, and
|
||||
storage-usage evidence must enter through `DockerData` and the typed Docker
|
||||
resource records (`docker-image`, `docker-volume`, `docker-network`,
|
||||
@@ -566,7 +579,9 @@ AI-only summary payloads, or page-local heuristics.
|
||||
hostname, `agent` platform scope, or agent telemetry alone; those facts
|
||||
surface as facets on the owning provider page.
|
||||
The default tab for each platform path must point at a sub-tab whose
|
||||
canonical unified-resource projection actually populates. The
|
||||
canonical unified-resource projection actually populates, and visible
|
||||
workflow subtabs must stay evidence-gated by the same canonical row or
|
||||
signal source instead of advertising empty object browsers. The
|
||||
canonical TrueNAS adapter (`internal/truenas/provider.go::
|
||||
truenasRecordsFromSnapshot`) already emits the top-level TrueNAS
|
||||
appliance as a unified `agent` row tagged with the `truenas`
|
||||
|
||||
@@ -99,17 +99,10 @@ export function DockerPageSurface() {
|
||||
<DockerOverview
|
||||
hosts={model().hosts}
|
||||
hostSourceCount={model().hosts.length}
|
||||
containers={model().containers}
|
||||
incidents={model().incidents}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'containers'}>
|
||||
<DockerContainersTable
|
||||
resources={model().containers}
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Docker or Podman containers"
|
||||
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
|
||||
/>
|
||||
</Show>
|
||||
<Show when={activeTab() === 'images'}>
|
||||
<DockerImagesTable
|
||||
resources={model().images}
|
||||
@@ -275,6 +268,7 @@ function DockerSwarm(props: { model: DockerPageModel }) {
|
||||
function DockerOverview(props: {
|
||||
hosts: ReturnType<typeof buildDockerPageModel>['hosts'];
|
||||
hostSourceCount: number;
|
||||
containers: ReturnType<typeof buildDockerPageModel>['containers'];
|
||||
incidents: ReturnType<typeof buildDockerPageModel>['incidents'];
|
||||
}) {
|
||||
return (
|
||||
@@ -287,6 +281,12 @@ function DockerOverview(props: {
|
||||
emptyDescription="Container hosts appear here once a Pulse agent registers them."
|
||||
showToolbar={false}
|
||||
/>
|
||||
<DockerContainersTable
|
||||
resources={props.containers}
|
||||
emptyIcon={dockerIcon()}
|
||||
emptyTitle="No Docker or Podman containers"
|
||||
emptyDescription="Containers appear here when a Docker or Podman host reports workload inventory."
|
||||
/>
|
||||
<Show when={props.incidents.length > 0}>
|
||||
<DockerAlertsTable
|
||||
incidents={props.incidents}
|
||||
|
||||
@@ -218,7 +218,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('DockerPageSurface', () => {
|
||||
it('keeps overview focused on runtime hosts while detailed inventory owns the object tabs', () => {
|
||||
it('keeps overview focused on runtime hosts plus primary container workloads', () => {
|
||||
render(() => <DockerPageSurface />);
|
||||
|
||||
expect(mocks.useUnifiedResources).toHaveBeenCalledWith(
|
||||
@@ -240,9 +240,16 @@ describe('DockerPageSurface', () => {
|
||||
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-show-toolbar', 'false');
|
||||
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute(
|
||||
'data-tabs',
|
||||
'overview,containers',
|
||||
'overview',
|
||||
);
|
||||
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
|
||||
'data-resource-count',
|
||||
'1',
|
||||
);
|
||||
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
|
||||
'data-show-toolbar',
|
||||
'undefined',
|
||||
);
|
||||
expect(screen.queryByTestId('docker-containers-table')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows Docker object tabs only when the matching inventory exists', () => {
|
||||
@@ -263,15 +270,17 @@ describe('DockerPageSurface', () => {
|
||||
|
||||
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute(
|
||||
'data-tabs',
|
||||
'overview,containers,images,storage,networks',
|
||||
'overview,images,storage,networks',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the dedicated containers route through the Docker containers table', () => {
|
||||
it('keeps the legacy containers route on the Overview landing surface', () => {
|
||||
mocks.pathname = '/docker/containers';
|
||||
|
||||
render(() => <DockerPageSurface />);
|
||||
|
||||
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview');
|
||||
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
|
||||
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
|
||||
'data-resource-count',
|
||||
'1',
|
||||
@@ -473,6 +482,9 @@ describe('DockerPageSurface', () => {
|
||||
expect(screen.queryByTestId('docker-volumes-table')).toBeNull();
|
||||
expect(screen.getByTestId('docker-section-tabs')).toHaveAttribute('data-active', 'overview');
|
||||
expect(screen.getByTestId('docker-hosts-table')).toHaveAttribute('data-resource-count', '1');
|
||||
expect(screen.queryByTestId('platform-table-empty-state')).toBeNull();
|
||||
expect(screen.getByTestId('docker-containers-table')).toHaveAttribute(
|
||||
'data-resource-count',
|
||||
'0',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,6 @@ describe('dockerPageModel', () => {
|
||||
it('declares operator workflow tabs for Docker runtime inventory', () => {
|
||||
expect(DOCKER_TAB_SPECS.map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
'containers',
|
||||
'images',
|
||||
'storage',
|
||||
'networks',
|
||||
@@ -98,7 +97,7 @@ describe('dockerPageModel', () => {
|
||||
|
||||
it('keeps legacy Docker object routes mapped to workflow tabs', () => {
|
||||
expect(resolveDockerPageTabId(undefined)).toBe('overview');
|
||||
expect(resolveDockerPageTabId('containers')).toBe('containers');
|
||||
expect(resolveDockerPageTabId('containers')).toBe('overview');
|
||||
expect(resolveDockerPageTabId('volumes')).toBe('storage');
|
||||
expect(resolveDockerPageTabId('storage')).toBe('storage');
|
||||
expect(resolveDockerPageTabId('services')).toBe('swarm');
|
||||
@@ -241,7 +240,6 @@ describe('dockerPageModel', () => {
|
||||
]);
|
||||
expect(getDockerPageTabSpecs(runtimeInventoryModel).map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
'containers',
|
||||
'images',
|
||||
'storage',
|
||||
'networks',
|
||||
|
||||
@@ -24,7 +24,6 @@ const DOCKER_CONFIG_TYPES = new Set<ResourceType>(['docker-config']);
|
||||
|
||||
export type DockerPageTabId =
|
||||
| 'overview'
|
||||
| 'containers'
|
||||
| 'images'
|
||||
| 'storage'
|
||||
| 'networks'
|
||||
@@ -38,11 +37,9 @@ export type DockerTabSpec = {
|
||||
|
||||
export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [
|
||||
// Keep the runtime lens at operator-workflow granularity. Overview owns
|
||||
// runtime hosts; detailed object inventory belongs in the Containers,
|
||||
// Images, Storage, Networks, and Swarm workflows so the page does not repeat
|
||||
// the same tables in multiple places.
|
||||
// runtime hosts plus primary container workloads; detailed supporting object
|
||||
// inventory belongs in the Images, Storage, Networks, and Swarm workflows.
|
||||
{ id: 'overview', label: 'Overview', path: '/docker/overview' },
|
||||
{ id: 'containers', label: 'Containers', path: '/docker/containers' },
|
||||
{ id: 'images', label: 'Images', path: '/docker/images' },
|
||||
{ id: 'storage', label: 'Storage', path: '/docker/storage' },
|
||||
{ id: 'networks', label: 'Networks', path: '/docker/networks' },
|
||||
@@ -52,6 +49,7 @@ export const DOCKER_TAB_SPECS: readonly DockerTabSpec[] = [
|
||||
const asTrimmedString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||
|
||||
const DOCKER_ROUTE_TAB_ALIASES: Record<string, DockerPageTabId> = {
|
||||
containers: 'overview',
|
||||
configs: 'swarm',
|
||||
secrets: 'swarm',
|
||||
services: 'swarm',
|
||||
@@ -610,8 +608,6 @@ const hasDockerTabInventory = (model: DockerPageModel, tab: DockerPageTabId): bo
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
return true;
|
||||
case 'containers':
|
||||
return model.containers.length > 0;
|
||||
case 'images':
|
||||
return model.images.length > 0;
|
||||
case 'storage':
|
||||
|
||||
@@ -26,9 +26,9 @@ import { KubernetesPolicyTable } from './KubernetesPolicyTable';
|
||||
import { KubernetesServicesTable } from './KubernetesServicesTable';
|
||||
import { KubernetesStorageTable } from './KubernetesStorageTable';
|
||||
import {
|
||||
KUBERNETES_TAB_SPECS,
|
||||
buildKubernetesPageModel,
|
||||
filterKubernetesResources,
|
||||
getKubernetesPageTabSpecs,
|
||||
resolveKubernetesPageTabId,
|
||||
type KubernetesPageModel,
|
||||
type KubernetesPageTabId,
|
||||
@@ -52,17 +52,21 @@ export function KubernetesPageSurface() {
|
||||
cacheKey: 'kubernetes-workspace',
|
||||
initialHydration: 'prefer-ws-then-rest',
|
||||
});
|
||||
const activeTab = createMemo<KubernetesPageTabId>(() => {
|
||||
const requestedTab = createMemo<KubernetesPageTabId>(() => {
|
||||
const segment = location.pathname.split('/').filter(Boolean)[1];
|
||||
return resolveKubernetesPageTabId(segment);
|
||||
});
|
||||
const model = createMemo(() => buildKubernetesPageModel(resources()));
|
||||
const tabs = createMemo(() => getKubernetesPageTabSpecs(model()));
|
||||
const activeTab = createMemo<KubernetesPageTabId>(() =>
|
||||
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
|
||||
);
|
||||
const controllerResources = createMemo(() => getKubernetesControllerResources(model()));
|
||||
|
||||
return (
|
||||
<div data-testid="kubernetes-page" class="space-y-3">
|
||||
<PlatformSectionTabs
|
||||
tabs={KUBERNETES_TAB_SPECS}
|
||||
tabs={tabs()}
|
||||
active={activeTab()}
|
||||
ariaLabel="Kubernetes sections"
|
||||
/>
|
||||
|
||||
+14
-2
@@ -153,7 +153,7 @@ describe('KubernetesPageSurface contract', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares workflow tabs while querying API-native Kubernetes resources', () => {
|
||||
it('declares inventory-backed workflow tabs while querying API-native Kubernetes resources', () => {
|
||||
setResources([
|
||||
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
|
||||
makeResource({ id: 'pod-1', type: 'pod' }),
|
||||
@@ -185,7 +185,7 @@ describe('KubernetesPageSurface contract', () => {
|
||||
);
|
||||
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute(
|
||||
'data-tabs',
|
||||
'overview,nodes,workloads,services,storage,configuration,events',
|
||||
'overview,workloads',
|
||||
);
|
||||
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
|
||||
expect(screen.getByTestId('clusters-table')).toHaveAttribute('data-rows', '1');
|
||||
@@ -194,6 +194,18 @@ describe('KubernetesPageSurface contract', () => {
|
||||
expect(screen.queryByTestId('controllers-table')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to Overview when a requested Kubernetes workflow has no inventory', () => {
|
||||
mockPathname.mockReturnValue('/kubernetes/services');
|
||||
setResources([makeResource({ id: 'cluster-1', type: 'k8s-cluster' })]);
|
||||
|
||||
renderSurface();
|
||||
|
||||
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-tabs', 'overview');
|
||||
expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview');
|
||||
expect(screen.getByTestId('clusters-table')).toHaveAttribute('data-rows', '1');
|
||||
expect(screen.queryByTestId('services-table')).toBeNull();
|
||||
});
|
||||
|
||||
it('groups workload API tables under the Workloads tab', () => {
|
||||
mockPathname.mockReturnValue('/kubernetes/workloads');
|
||||
setResources([
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
compareKubernetesEvents,
|
||||
compareKubernetesNodes,
|
||||
compareKubernetesPods,
|
||||
getKubernetesPageTabSpecs,
|
||||
mapKubernetesControllerStatus,
|
||||
mapKubernetesCronJobStatus,
|
||||
mapKubernetesDaemonSetStatus,
|
||||
@@ -64,6 +65,32 @@ describe('kubernetesPageModel', () => {
|
||||
expect(resolveKubernetesPageTabId('unknown')).toBe('overview');
|
||||
});
|
||||
|
||||
it('shows Kubernetes workflow tabs only when the workflow has inventory', () => {
|
||||
const clusterOnlyModel = buildKubernetesPageModel([
|
||||
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
|
||||
]);
|
||||
const workloadModel = buildKubernetesPageModel([
|
||||
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
|
||||
makeResource({ id: 'node-1', type: 'k8s-node' }),
|
||||
makeResource({ id: 'pod-1', type: 'pod' }),
|
||||
makeResource({ id: 'svc-1', type: 'k8s-service' }),
|
||||
makeResource({ id: 'pvc-1', type: 'k8s-persistent-volume-claim' }),
|
||||
makeResource({ id: 'cm-1', type: 'k8s-configmap' }),
|
||||
makeResource({ id: 'event-1', type: 'k8s-event' }),
|
||||
]);
|
||||
|
||||
expect(getKubernetesPageTabSpecs(clusterOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
|
||||
expect(getKubernetesPageTabSpecs(workloadModel).map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
'nodes',
|
||||
'workloads',
|
||||
'services',
|
||||
'storage',
|
||||
'configuration',
|
||||
'events',
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets clusters, nodes, workloads, services, storage, config, policy, autoscaling, and events', () => {
|
||||
const model = buildKubernetesPageModel([
|
||||
makeResource({ id: 'cluster-1', type: 'k8s-cluster' }),
|
||||
|
||||
@@ -566,6 +566,33 @@ export const resolveKubernetesPageTabId = (segment: string | undefined): Kuberne
|
||||
return KUBERNETES_ROUTE_TAB_ALIASES[normalized] ?? 'overview';
|
||||
};
|
||||
|
||||
const hasKubernetesTabInventory = (
|
||||
model: KubernetesPageModel,
|
||||
tab: KubernetesPageTabId,
|
||||
): boolean => {
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
return true;
|
||||
case 'nodes':
|
||||
return model.nodes.length > 0;
|
||||
case 'workloads':
|
||||
return model.workloads.length > 0 || model.autoscaling.length > 0;
|
||||
case 'services':
|
||||
return model.services.length > 0 || model.serviceNetworking.length > 0;
|
||||
case 'storage':
|
||||
return model.storage.length > 0;
|
||||
case 'configuration':
|
||||
return model.config.length > 0 || model.policy.length > 0;
|
||||
case 'events':
|
||||
return model.events.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getKubernetesPageTabSpecs = (
|
||||
model: KubernetesPageModel,
|
||||
): readonly KubernetesTabSpec[] =>
|
||||
KUBERNETES_TAB_SPECS.filter((tab) => hasKubernetesTabInventory(model, tab.id));
|
||||
|
||||
const KUBERNETES_RESOURCE_TYPES = new Set<ResourceType>([
|
||||
'k8s-cluster',
|
||||
'k8s-node',
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TrueNASVirtualMachinesTable } from './TrueNASVirtualMachinesTable';
|
||||
import {
|
||||
TRUENAS_TAB_SPECS,
|
||||
buildTrueNASPageModel,
|
||||
getTrueNASPageTabSpecs,
|
||||
type TrueNASPageModel,
|
||||
type TrueNASPageTabId,
|
||||
} from './truenasPageModel';
|
||||
@@ -46,16 +47,34 @@ export function TrueNASPageSurface() {
|
||||
cacheKey: 'truenas-workspace',
|
||||
initialHydration: 'prefer-ws-then-rest',
|
||||
});
|
||||
const activeTab = createMemo<TrueNASPageTabId>(() => {
|
||||
const requestedTab = createMemo<TrueNASPageTabId>(() => {
|
||||
const segment = location.pathname.split('/').filter(Boolean)[1] as TrueNASPageTabId | undefined;
|
||||
return segment && VALID_TABS.has(segment) ? segment : 'overview';
|
||||
});
|
||||
const model = createMemo(() => buildTrueNASPageModel(resources()));
|
||||
const protection = useRecoveryPoints(() =>
|
||||
model().resources.length > 0
|
||||
? {
|
||||
platform: TRUENAS_PLATFORM_FILTER,
|
||||
page: 1,
|
||||
limit: 200,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const hasProtectionInventory = createMemo(
|
||||
() => protection.meta().total > 0 || protection.points().length > 0,
|
||||
);
|
||||
const tabs = createMemo(() =>
|
||||
getTrueNASPageTabSpecs(model(), { hasProtectionInventory: hasProtectionInventory() }),
|
||||
);
|
||||
const activeTab = createMemo<TrueNASPageTabId>(() =>
|
||||
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="truenas-page" class="space-y-3">
|
||||
<PlatformSectionTabs
|
||||
tabs={TRUENAS_TAB_SPECS}
|
||||
tabs={tabs()}
|
||||
active={activeTab()}
|
||||
ariaLabel="TrueNAS sections"
|
||||
/>
|
||||
@@ -108,7 +127,7 @@ export function TrueNASPageSurface() {
|
||||
<TrueNASShares model={model} />
|
||||
</Show>
|
||||
<Show when={activeTab() === 'protection'}>
|
||||
<TrueNASProtection />
|
||||
<TrueNASProtection recoveryPoints={protection} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -180,19 +199,13 @@ function TrueNASShares(props: TrueNASOverviewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function TrueNASProtection() {
|
||||
const recoveryPoints = useRecoveryPoints(() => ({
|
||||
platform: TRUENAS_PLATFORM_FILTER,
|
||||
page: 1,
|
||||
limit: 200,
|
||||
}));
|
||||
|
||||
function TrueNASProtection(props: { recoveryPoints: ReturnType<typeof useRecoveryPoints> }) {
|
||||
return (
|
||||
<TrueNASProtectionTable
|
||||
points={recoveryPoints.points()}
|
||||
loading={recoveryPoints.response.loading}
|
||||
error={recoveryPoints.response.error}
|
||||
onRefresh={() => void recoveryPoints.refetch()}
|
||||
points={props.recoveryPoints.points()}
|
||||
loading={props.recoveryPoints.response.loading}
|
||||
error={props.recoveryPoints.response.error}
|
||||
onRefresh={() => void props.recoveryPoints.refetch()}
|
||||
emptyIcon={truenasIcon()}
|
||||
emptyTitle="No TrueNAS protection activity"
|
||||
emptyDescription="ZFS snapshots and replication tasks appear here once the TrueNAS API reports snapshot or replication activity."
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
filterTrueNASStorageTopologyRows,
|
||||
filterTrueNASShares,
|
||||
filterTrueNASVMs,
|
||||
getTrueNASPageTabSpecs,
|
||||
mapTrueNASAppStatus,
|
||||
mapTrueNASIncidentSeverity,
|
||||
mapTrueNASProtectionKind,
|
||||
@@ -60,6 +61,34 @@ describe('truenasPageModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows TrueNAS workflow tabs only when their native facet has inventory', () => {
|
||||
const systemOnlyModel = buildTrueNASPageModel([
|
||||
makeResource({ id: 'truenas-system', type: 'agent' }),
|
||||
]);
|
||||
const inventoryModel = buildTrueNASPageModel([
|
||||
makeResource({
|
||||
id: 'truenas-system',
|
||||
type: 'agent',
|
||||
truenas: { services: [{ id: '1', service: 'smb', enabled: true, state: 'RUNNING' }] },
|
||||
}),
|
||||
makeResource({ id: 'truenas-vm', type: 'vm' }),
|
||||
makeResource({ id: 'truenas-app', type: 'app-container' }),
|
||||
makeResource({ id: 'truenas-share', type: 'network-share' }),
|
||||
makeResource({
|
||||
id: 'truenas-pool',
|
||||
type: 'storage',
|
||||
storage: { topology: 'pool', platform: 'truenas' },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(getTrueNASPageTabSpecs(systemOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
|
||||
expect(
|
||||
getTrueNASPageTabSpecs(inventoryModel, { hasProtectionInventory: true }).map(
|
||||
(tab) => tab.id,
|
||||
),
|
||||
).toEqual(['overview', 'storage', 'services', 'apps', 'vms', 'shares', 'protection']);
|
||||
});
|
||||
|
||||
it('buckets systems, workloads, and native storage inventory by TrueNAS API facet', () => {
|
||||
const model = buildTrueNASPageModel([
|
||||
makeResource({
|
||||
|
||||
@@ -52,6 +52,10 @@ export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [
|
||||
{ id: 'protection', label: 'Protection', path: '/truenas/protection' },
|
||||
] as const;
|
||||
|
||||
export type TrueNASTabInventoryOptions = {
|
||||
hasProtectionInventory?: boolean;
|
||||
};
|
||||
|
||||
const TRUENAS_RESOURCE_TYPES = new Set<ResourceType>([
|
||||
'agent',
|
||||
'vm',
|
||||
@@ -176,6 +180,38 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel {
|
||||
};
|
||||
}
|
||||
|
||||
const hasTrueNASStorageInventory = (model: TrueNASPageModel): boolean =>
|
||||
model.pools.length > 0 || model.datasets.length > 0 || model.disks.length > 0;
|
||||
|
||||
const hasTrueNASTabInventory = (
|
||||
model: TrueNASPageModel,
|
||||
tab: TrueNASPageTabId,
|
||||
options: TrueNASTabInventoryOptions = {},
|
||||
): boolean => {
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
return true;
|
||||
case 'storage':
|
||||
return hasTrueNASStorageInventory(model);
|
||||
case 'services':
|
||||
return model.services.length > 0;
|
||||
case 'apps':
|
||||
return model.apps.length > 0;
|
||||
case 'vms':
|
||||
return model.vms.length > 0;
|
||||
case 'shares':
|
||||
return model.shares.length > 0;
|
||||
case 'protection':
|
||||
return Boolean(options.hasProtectionInventory);
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrueNASPageTabSpecs = (
|
||||
model: TrueNASPageModel,
|
||||
options: TrueNASTabInventoryOptions = {},
|
||||
): readonly TrueNASTabSpec[] =>
|
||||
TRUENAS_TAB_SPECS.filter((tab) => hasTrueNASTabInventory(model, tab.id, options));
|
||||
|
||||
const isTrueNASPoolResource = (resource: Resource): boolean =>
|
||||
resource.type === 'pool' || resource.storage?.topology === 'pool';
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { VsphereHostsTable } from './VsphereHostsTable';
|
||||
import {
|
||||
VMWARE_TAB_SPECS,
|
||||
buildVmwarePageModel,
|
||||
getVmwarePageTabSpecs,
|
||||
type VmwarePageModel,
|
||||
type VmwarePageTabId,
|
||||
} from './vmwarePageModel';
|
||||
@@ -71,12 +72,12 @@ export function VmwarePageSurface() {
|
||||
cacheKey: 'vmware-workspace',
|
||||
initialHydration: 'prefer-ws-then-rest',
|
||||
});
|
||||
const activeTab = createMemo<VmwarePageTabId>(() => {
|
||||
const requestedTab = createMemo<VmwarePageTabId>(() => {
|
||||
const segment = location.pathname.split('/').filter(Boolean)[1] as VmwarePageTabId | undefined;
|
||||
return segment && VALID_TABS.has(segment) ? segment : 'overview';
|
||||
});
|
||||
const [activityTimeline, { refetch: refetchActivityTimeline }] = createResource(
|
||||
() => (activeTab() === 'activity' ? 'vmware-activity' : undefined),
|
||||
() => (resources().length > 0 ? 'vmware-activity' : undefined),
|
||||
async () => {
|
||||
const response = await ResourceAPI.getGlobalTimeline({
|
||||
limit: 100,
|
||||
@@ -88,6 +89,10 @@ export function VmwarePageSurface() {
|
||||
},
|
||||
);
|
||||
const model = createMemo(() => buildVmwarePageModel(resources(), activityTimeline() ?? []));
|
||||
const tabs = createMemo(() => getVmwarePageTabSpecs(model()));
|
||||
const activeTab = createMemo<VmwarePageTabId>(() =>
|
||||
tabs().some((tab) => tab.id === requestedTab()) ? requestedTab() : 'overview',
|
||||
);
|
||||
|
||||
// Hosts table on top and the embedded WorkloadsSurface below share the
|
||||
// bars/sparklines toggle (and the sparkline history range that ships with
|
||||
@@ -114,7 +119,7 @@ export function VmwarePageSurface() {
|
||||
return (
|
||||
<div data-testid="vmware-page" class="space-y-3">
|
||||
<PlatformSectionTabs
|
||||
tabs={VMWARE_TAB_SPECS}
|
||||
tabs={tabs()}
|
||||
active={activeTab()}
|
||||
ariaLabel="VMware sections"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
filterVmwareIncidents,
|
||||
filterVmwareNetworks,
|
||||
filterVmwareVirtualMachines,
|
||||
getVmwarePageTabSpecs,
|
||||
mapVmwareActivityStateBucket,
|
||||
mapVmwareDatastoreStatus,
|
||||
mapVmwareIncidentSeverity,
|
||||
@@ -44,6 +45,62 @@ describe('vmwarePageModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows vSphere workflow tabs only when their inventory or signal exists', () => {
|
||||
const hostOnlyModel = buildVmwarePageModel([makeResource({ id: 'esxi-host-1', type: 'agent' })]);
|
||||
const fullModel = buildVmwarePageModel(
|
||||
[
|
||||
makeResource({ id: 'esxi-host-1', type: 'agent' }),
|
||||
makeResource({
|
||||
id: 'datastore-1',
|
||||
type: 'storage',
|
||||
storage: { topology: 'datastore', platform: 'vmware-vsphere' },
|
||||
vmware: { entityType: 'datastore' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'network-1',
|
||||
type: 'network',
|
||||
vmware: { entityType: 'network', networkType: 'STANDARD_PORTGROUP' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'alarm-1',
|
||||
type: 'vm',
|
||||
incidents: [
|
||||
{
|
||||
code: 'alarm-1',
|
||||
severity: 'warning',
|
||||
summary: 'Triggered alarm',
|
||||
startedAt: '2026-05-20T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'task-1',
|
||||
resourceId: 'esxi-host-1',
|
||||
observedAt: '2026-05-20T00:00:00Z',
|
||||
sourceType: 'platform_event',
|
||||
sourceAdapter: 'vmware_adapter',
|
||||
confidence: 'high',
|
||||
kind: 'activity',
|
||||
metadata: {
|
||||
activity_title: 'vCenter task',
|
||||
activity_message: 'Task completed',
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(getVmwarePageTabSpecs(hostOnlyModel).map((tab) => tab.id)).toEqual(['overview']);
|
||||
expect(getVmwarePageTabSpecs(fullModel).map((tab) => tab.id)).toEqual([
|
||||
'overview',
|
||||
'storage',
|
||||
'networks',
|
||||
'health',
|
||||
'activity',
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets canonical vSphere hosts, VMs, datastores, and networks', () => {
|
||||
const model = buildVmwarePageModel([
|
||||
makeResource({ id: 'esxi-host-1', type: 'agent' }),
|
||||
|
||||
@@ -135,6 +135,24 @@ export function buildVmwarePageModel(
|
||||
};
|
||||
}
|
||||
|
||||
const hasVmwareTabInventory = (model: VmwarePageModel, tab: VmwarePageTabId): boolean => {
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
return true;
|
||||
case 'storage':
|
||||
return model.datastores.length > 0;
|
||||
case 'networks':
|
||||
return model.networks.length > 0;
|
||||
case 'health':
|
||||
return model.incidents.length > 0;
|
||||
case 'activity':
|
||||
return model.activity.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVmwarePageTabSpecs = (model: VmwarePageModel): readonly VmwareTabSpec[] =>
|
||||
VMWARE_TAB_SPECS.filter((tab) => hasVmwareTabInventory(model, tab.id));
|
||||
|
||||
const normalize = (value: unknown): string =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user