mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-28 06:27:26 +00:00
Bring storage and platform drawer content up to the guest drawer (#3113)
The Proxmox guest and node drawers lead with what is wrong, chart the last day and keep the long tail in one closed Details. Other drawers did not: - Storage pool and physical-disk details on the Proxmox Storage tab had no heading, status dot or collapse control, no alerts line or Worth checking, a pool chart defaulting to 7 days with its own picker, a disk "History" and "Live I/O" section, and every config, topology and SMART table open by default. Both are now object drawers in the shared detail row, with Overview (alerts, Worth checking, 24-hour charts, facts, ZFS/SMART cards, closed Details) and Manage (operator state). A disk alert that already states the verdict is not repeated beneath it, and worn flash is stated once by the verdict. - TrueNAS ZFS errors and disk risk, vSphere datastores that are not accessible, Kubernetes containers that are not ready and cordoned nodes sat two or three closed disclosures deep and never reached Worth checking. Platform sections now join the same split as every other drawer: warnings lift, cards show, the rest waits in Details. - Kubernetes Deployments, autoscalers, Services, Ingresses and volumes had no section at all, so columns hidden on phones were unreachable. Docker image and volume drawers showed nothing about the image or volume. No field is dropped; it moves to where the guest drawer keeps it.
This commit is contained in:
@@ -10011,3 +10011,11 @@ remains visible beside invalid capacity without formatting NaN/Infinity bytes.
|
||||
Model/component controls cover non-finite, absent and non-positive capacity;
|
||||
`workload-disk-metrics.cjs` checks both Bars (including the tooltip) and Sparklines
|
||||
through the same REST reading at desktop and phone widths.
|
||||
|
||||
The storage pool drawer left the `form-select-shell` consumer list: its usage
|
||||
chart now takes its range from the shared drawer history toolbar
|
||||
(`GuestDrawerHistory`), the same control as the guest and node drawers, rather
|
||||
than a pool-local `FormSelect`. The guest drawer's embedded disk detail keeps
|
||||
its `FormSelect`. Guardrails that pinned the retired pool card classes now pin
|
||||
the drawer pieces the pool Overview composes (attention section, Worth
|
||||
checking, drawer history, closed Details of `DetailSectionTable` sections).
|
||||
|
||||
@@ -4446,13 +4446,11 @@ the record's `details.zfsPool`, which
|
||||
`frontend-modern/src/components/Storage/StoragePoolDetail.tsx` renders that
|
||||
report inside the row expansion so degraded pools name the failing device and
|
||||
running scrub/resilver without re-promoting per-device noise into table rows.
|
||||
The pool detail key/value rows span the full Configuration card below the
|
||||
small-screen breakpoint, then return to the two-column desktop grid. Labels
|
||||
stay fixed while long node, content, and provider values wrap. Phones retain
|
||||
the compact endpoint alignment, while the desktop grid uses the shared 7rem
|
||||
label track and places each left-aligned value immediately beside its label,
|
||||
so wide drawers remain easy to scan and narrow row expansions never
|
||||
concatenate one field with the next or overflow the drawer.
|
||||
The pool's Configuration, Topology and Datasets now render as
|
||||
`DetailSectionTable` sections inside the drawer's closed Details disclosure;
|
||||
long node, content, provider, dataset and mountpoint values wrap, so narrow
|
||||
row expansions never concatenate one field with the next or overflow the
|
||||
drawer.
|
||||
The pools table must not double-list Ceph-backed storage. Cluster-internal
|
||||
pool rows synthesized from Ceph cluster telemetry (`models.StorageFromCephPool`:
|
||||
type `ceph` homed on the `cluster` pseudo-node) are consolidated into the PVE
|
||||
@@ -7095,3 +7093,24 @@ drawer's status dot is the one its row shows (reachable or not), not a second
|
||||
reading of the raw status, and the Coverage detail row no longer overrides the
|
||||
shared detail shell's padding, which had dropped its phone width cap. Neither
|
||||
change alters how coverage, protection posture or restore evidence is judged.
|
||||
|
||||
Storage pool and physical-disk drawers follow the Proxmox guest and node
|
||||
drawer shape. Both open in the shared `InlineDetailTableRow` (one open at a
|
||||
time, `aria-controls` focus restore, phone width cap) under an
|
||||
`ObjectDrawerHeader` with `DrawerSubjectHeading`, whose dot reads the same
|
||||
judgement as the row (`getStoragePoolStateIndicator`,
|
||||
`getPhysicalDiskHealthIndicator`). Overview leads with the object's own alerts
|
||||
(the set that tints its row: `getRecordAlerts`, and `getPhysicalDiskAlerts`
|
||||
matching the disk's unified keys, its Proxmox disk alias and its agent's disk
|
||||
temperature key, never a sibling, machine or node alert), then "Worth
|
||||
checking" for a bad state, reduced protection, ZFS pool and scrub errors,
|
||||
devices that are not online, linked disks with errors or reallocated sectors,
|
||||
out-of-range SMART attributes and low SSD life. Charts use the shared drawer
|
||||
history at 24 hours with the range picker in their toolbar (pool usage; disk
|
||||
temperature, I/O and wear), then a facts line, content cards (ZFS pool and
|
||||
devices, linked disks, SMART health) and one closed Details. Manage holds the
|
||||
operator state for the pool or disk's unified resource id. The separate 7-day
|
||||
pool chart, the disk drawer's own History and Live I/O sections and the
|
||||
pool-local range picker are retired; no field was dropped. The guest drawer's
|
||||
embedded physical-disk detail (`GuestPhysicalDisks` -> `DiskDetail`) keeps its
|
||||
existing layout for now.
|
||||
|
||||
@@ -8598,3 +8598,19 @@ no table changes how it judges a resource. The drawer heading is the shared
|
||||
a saved web interface URL opens from the header the way it does in the Proxmox
|
||||
guest drawer. The health-issue chip left the heading because the same issue
|
||||
already leads the Overview's "Worth checking" list.
|
||||
|
||||
Platform drawer content follows the guest and node Overview. The TrueNAS,
|
||||
Kubernetes and vSphere sections join the summary sections in one split
|
||||
(`buildResourceDrawerOverviewInventory`): warning and danger rows not already
|
||||
stated by an open alert lift into "Worth checking", sections whose test id
|
||||
marks them as content cards (pod containers, TrueNAS pool health and disk
|
||||
SMART) render in view, and every other section sits in the one closed Details
|
||||
as a plain `DetailSectionTable`; the nested "Show TrueNAS / Kubernetes /
|
||||
vSphere" disclosures are gone. Kubernetes Deployments, autoscalers, Services,
|
||||
Ingresses, volume claims, persistent volumes and storage classes gain their own
|
||||
sections from fields the resource already carries, a replica shortfall or a
|
||||
Pending/Lost/Failed phase lifts, and a pod's namespace, owner and node join its
|
||||
facts line. Docker image and volume drawers describe the image or volume (update
|
||||
check, consumers, size, tags, digests; driver, scope, mountpoint, labels), with
|
||||
an available or failed update check lifted. Copyable identifiers wrap. This
|
||||
changes presentation only: no status, alert or ownership rule moves.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "5d6d9e2dcf4f527080d36d3834acbe03481ae5d6",
|
||||
"verified_at": "2026-10-22T13:25:18Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerOverviewModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts",
|
||||
"frontend-modern/src/components/Storage/DiskDrawer.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskDrawerOverview.tsx",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx",
|
||||
"frontend-modern/src/components/Storage/StorageContentCard.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDetail.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDrawer.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx",
|
||||
"frontend-modern/src/components/Storage/physicalDiskAlertOwner.ts",
|
||||
"frontend-modern/src/components/Storage/useStorageAlertState.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageData.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePageResources.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts",
|
||||
"frontend-modern/src/components/Workloads/drawerOverviewModel.ts",
|
||||
"frontend-modern/src/features/storageBackups/detailPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskDrawerPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/rowPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDrawerPresentation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "2fc7d6488dd70ab531e372b42daca9b025e729c7197dbc75cd4179bd3f6fd01d",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "9887aa35c3d4c35d415929b9c30582cb3c7c5023c7c2518aa2b8f59e3716fe15",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "eada712058c26d3d5c3c2bfd590f5151c9d30ead7ed2ecd58f8e06c3af3277eb",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerOverviewModel.ts": "3345c1c29aec8326819de52a3247ea43b68292da2da3f4b6dec03faea9c9e256",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts": "db4ff73655486afcd19d79ab33025b5f4498165e89bb18a2ac9ab29320eb4af6",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts": "f0f78c1bc0befda6936c84e53c53d917e5128df60bc6b34cb6ea10cc39ecb331",
|
||||
"frontend-modern/src/components/Storage/DiskDrawer.tsx": "bb9e8ad48ea9cc73d8b0c2aee073377527d861b8a19a54c64f0037932ae05f5e",
|
||||
"frontend-modern/src/components/Storage/DiskDrawerOverview.tsx": "f99922d63d6a9c0b6572262ba066ccd617eb5d453885add8de01027cdb0c1ace",
|
||||
"frontend-modern/src/components/Storage/DiskList.tsx": "762e75ac966f269c25ea235cacfe55f3b1046bcafccc40b3e6fae0bb5166d6e5",
|
||||
"frontend-modern/src/components/Storage/Storage.tsx": "608eb538c85d66ac1d71bc83ffc727496f1d7301b61edee50a038e3cd6e6eba4",
|
||||
"frontend-modern/src/components/Storage/StorageContentCard.tsx": "2a8a6ab0319624df63733a5914b37b6f42d8e06a983cfc65ef5381b8748cb9de",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDetail.tsx": "1c91b24f4c5aca1fcb21778a0990445b48c1fcd683eef17736b083cc10f403d6",
|
||||
"frontend-modern/src/components/Storage/StoragePoolDrawer.tsx": "7e34401eec92df3523358df799f20fb2cff3362f8368d8b56a8a6293b6c9f475",
|
||||
"frontend-modern/src/components/Storage/StoragePoolRow.tsx": "a16ac3be831959990c85ae781c92f1316752bf46e2741344185c34bd40fb53a3",
|
||||
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "6e25a5a63255e084ababbbaa56829715b6576fa59ad3771014ab4aa14fb4ebbf",
|
||||
"frontend-modern/src/components/Storage/physicalDiskAlertOwner.ts": "e92aa580a77cead6cbdd1f7aa003a491c84d2bc412fa032f1d19125b6c6ddfd5",
|
||||
"frontend-modern/src/components/Storage/useStorageAlertState.ts": "3fe9253379b640738940f885708fec2c1a825d642dd723d78704a68de7e24ba4",
|
||||
"frontend-modern/src/components/Storage/useStoragePageData.ts": "1ef59a2a8f7b1c81f9bb2d539036e13a1fe1a74d25acde3899053c3e32a29721",
|
||||
"frontend-modern/src/components/Storage/useStoragePageModel.ts": "995e274607c6748e1a906f80892fd7ac44ccebbb830794a9d54ddc19d1822c71",
|
||||
"frontend-modern/src/components/Storage/useStoragePageResources.ts": "dd27e1a30fe89cfa795332d8f139402b27814501ed23f1ce68a303878d8598c8",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolDetailModel.ts": "20d6e2c001b79d672a228b9e79eb1c47804f48847a18f467524a9b1da3195350",
|
||||
"frontend-modern/src/components/Workloads/drawerOverviewModel.ts": "8b38b5e99eabb4b76141ef0c647c791bc3eed7de193284a25fac5229ecebd908",
|
||||
"frontend-modern/src/features/storageBackups/detailPresentation.ts": "33af953c86beb59ee1cf03900ab26a0e612e74187d2b06967d1b07e7362891e0",
|
||||
"frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "22257147f6fba937b40a8dd5d1cdd1c52597e5ed2541305e56925d93acc44fd3",
|
||||
"frontend-modern/src/features/storageBackups/diskDrawerPresentation.ts": "b821956b1a9c419510382d3097245d4949adec9603dac663e95063e6d4bf598f",
|
||||
"frontend-modern/src/features/storageBackups/diskPresentation.ts": "45e81ce3ac38a6b181afaaf8f8ffa9e73a55df7786f1dd21f59390c2b68b3f03",
|
||||
"frontend-modern/src/features/storageBackups/rowPresentation.ts": "3e8c6112e2436bc56ac6373e5d4bf89094e1a71a67183a86cfdc4732738b9c06",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts": "8223f31effe6d84a93e37f706e219708e6e538904a00e5ac7f8f5aa494951b40",
|
||||
"frontend-modern/src/features/storageBackups/storagePoolDrawerPresentation.ts": "12f33075e3dc57d18ba9a771b798921e82853a40de4e9dc4574c5167000c8b2e"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/storage?tab=disks",
|
||||
"/docker/overview",
|
||||
"/docker/images",
|
||||
"/docker/storage",
|
||||
"/kubernetes/overview",
|
||||
"/kubernetes/nodes",
|
||||
"/kubernetes/workloads",
|
||||
"/kubernetes/services",
|
||||
"/kubernetes/storage",
|
||||
"/truenas/overview",
|
||||
"/truenas/storage",
|
||||
"/truenas/storage?kind=disks",
|
||||
"/truenas/apps",
|
||||
"/truenas/vms",
|
||||
"/truenas/shares",
|
||||
"/vmware/overview",
|
||||
"/vmware/storage",
|
||||
"/vmware/networks",
|
||||
"/standalone/machines",
|
||||
"/standalone/availability"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Embedded production build of this commit on the full mock estate, signed in through the real login; first drawer of every expandable table on each route opened (warning/critical row preferred) and compared with the same probe on origin/main",
|
||||
"Proxmox storage pool and physical-disk drawers open under the clicked row with the collapse header, row-matching status dot, Overview/Manage, alerts line, Worth checking (degraded ZFS pool, resilvering, device errors, SSD wear), 24-hour charts with the range picker in their toolbar, facts line, ZFS/SMART cards and a closed Details",
|
||||
"A disk whose wearout alert is open shows the alert once: no Worth checking repeat, verdict kept (wrapping) in the SMART health card",
|
||||
"TrueNAS, vSphere and Kubernetes drawers lift platform warnings (degraded pool, inaccessible datastore, replica shortfalls, not-ready containers) into Worth checking; pod containers show as a card; nested platform disclosures gone from Details",
|
||||
"Docker image and volume drawers describe the image (update check, consumers, size, tags, digests) and volume (driver, scope, mountpoint, labels)",
|
||||
"No console or page errors on any route at either width"
|
||||
],
|
||||
"interactions": [
|
||||
"Disclosure button and whole-row click open each drawer; a second row closes the first",
|
||||
"Header click collapses the drawer",
|
||||
"Overview -> Manage -> Overview keeps Overview; Manage shows operator state for pools and disks",
|
||||
"Phone width shows the first chart with a Show toggle for the rest"
|
||||
],
|
||||
"browser": {
|
||||
"client": "playwright 1.62.1",
|
||||
"engine": "Chromium",
|
||||
"version": "chromium-1234 headless"
|
||||
},
|
||||
"scope": "Full authenticated application on a mock estate; mock data, not live provider connections."
|
||||
}
|
||||
@@ -1599,7 +1599,6 @@
|
||||
{ "path": "src/components/Settings/SystemLogsPanel.tsx" },
|
||||
{ "path": "src/components/Storage/DiskDetail.tsx" },
|
||||
{ "path": "src/components/Storage/StoragePageControls.tsx" },
|
||||
{ "path": "src/components/Storage/StoragePoolDetail.tsx" },
|
||||
{ "path": "src/components/Workloads/GuestDrawerHistory.tsx" },
|
||||
{ "path": "src/components/shared/FilterBar/AddFilterMenu.tsx" },
|
||||
{ "path": "src/components/shared/FilterToolbar.tsx" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show } from 'solid-js';
|
||||
import { For, Show, createMemo } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
|
||||
import type {
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
getAlertStatedRowLabels,
|
||||
getDrawerChartAlerts,
|
||||
isDrawerAvailabilityHealthy,
|
||||
partitionDrawerExceptionRows,
|
||||
} from '@/components/Workloads/drawerOverviewModel';
|
||||
import { DrawerExceptionList } from '@/components/Workloads/DrawerExceptionList';
|
||||
import { ALERTS_EMPTY_STATE } from '@/utils/alertOverviewPresentation';
|
||||
@@ -44,7 +43,10 @@ import { ResourceFacetSummary } from './ResourceFacetSummary';
|
||||
import { getResourceDrawerFacts, useResourceSummarySections } from './ResourceDetailSummary';
|
||||
import { ResourceInvestigationContextTables } from './ResourceInvestigationContextTables';
|
||||
import { DetailSectionTable } from '@/components/shared/DetailSectionTable';
|
||||
import { TechnicalDetailsDisclosure } from '@/components/shared/TechnicalDetailsDisclosure';
|
||||
import {
|
||||
TechnicalDetailsDisclosure,
|
||||
TechnicalDetailsSection,
|
||||
} from '@/components/shared/TechnicalDetailsDisclosure';
|
||||
import {
|
||||
DrawerAttentionSection,
|
||||
type DrawerAttentionItem,
|
||||
@@ -64,6 +66,7 @@ import { shouldShowResourcePlatformId } from '@/utils/resourceIdentity';
|
||||
import { isPulseAgentPlatformResource } from '@/utils/agentResources';
|
||||
import { formatInteger } from './resourceDetailMappers';
|
||||
import { buildPbsJobHealthEvidenceModel } from './resourceDetailDrawerServiceModel';
|
||||
import { buildResourceDrawerOverviewInventory } from './resourceDetailDrawerOverviewModel';
|
||||
import { AvailabilityProbeStatusCards } from './AvailabilityProbeStatusCard';
|
||||
import { ResourceDetailDrawerSupportDisclosure as SupportDisclosure } from './ResourceDetailDrawerSupportDisclosure';
|
||||
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
||||
@@ -78,66 +81,6 @@ interface ResourceDetailDrawerOverviewTabProps {
|
||||
const hasMetadataEntries = (value?: Record<string, unknown> | null): boolean =>
|
||||
Boolean(value && Object.keys(value).length > 0);
|
||||
|
||||
const TrueNASDetailsDisclosure: Component<{
|
||||
drawer: UseResourceDetailDrawerStateResult;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
}> = (props) => (
|
||||
<SupportDisclosure
|
||||
title="TrueNAS"
|
||||
summary={props.drawer.trueNASDetailsSummary()}
|
||||
expanded={props.drawer.showTrueNASDetails()}
|
||||
onToggle={() => props.drawer.setShowTrueNASDetails((value) => !value)}
|
||||
showLabel="Show TrueNAS"
|
||||
hideLabel="Hide TrueNAS"
|
||||
class={props.class}
|
||||
contentClass={props.contentClass ?? 'mt-3 space-y-3'}
|
||||
dataTestId="resource-truenas-details-section"
|
||||
>
|
||||
<DetailSectionTable sections={props.drawer.trueNASDetailSections()} />
|
||||
</SupportDisclosure>
|
||||
);
|
||||
|
||||
const KubernetesDetailsDisclosure: Component<{
|
||||
drawer: UseResourceDetailDrawerStateResult;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
}> = (props) => (
|
||||
<SupportDisclosure
|
||||
title="Kubernetes"
|
||||
summary={props.drawer.kubernetesDetailsSummary()}
|
||||
expanded={props.drawer.showKubernetesDetails()}
|
||||
onToggle={() => props.drawer.setShowKubernetesDetails((value) => !value)}
|
||||
showLabel="Show Kubernetes"
|
||||
hideLabel="Hide Kubernetes"
|
||||
class={props.class}
|
||||
contentClass={props.contentClass ?? 'mt-3 space-y-3'}
|
||||
dataTestId="resource-kubernetes-details-section"
|
||||
>
|
||||
<DetailSectionTable sections={props.drawer.kubernetesDetailSections()} />
|
||||
</SupportDisclosure>
|
||||
);
|
||||
|
||||
const VMwareDetailsDisclosure: Component<{
|
||||
drawer: UseResourceDetailDrawerStateResult;
|
||||
class?: string;
|
||||
contentClass?: string;
|
||||
}> = (props) => (
|
||||
<SupportDisclosure
|
||||
title="vSphere"
|
||||
summary={props.drawer.vmwareDetailsSummary()}
|
||||
expanded={props.drawer.showVMwareDetails()}
|
||||
onToggle={() => props.drawer.setShowVMwareDetails((value) => !value)}
|
||||
showLabel="Show vSphere"
|
||||
hideLabel="Hide vSphere"
|
||||
class={props.class}
|
||||
contentClass={props.contentClass ?? 'mt-3 space-y-3'}
|
||||
dataTestId="resource-vmware-details-section"
|
||||
>
|
||||
<DetailSectionTable sections={props.drawer.vmwareDetailSections()} />
|
||||
</SupportDisclosure>
|
||||
);
|
||||
|
||||
const machineHostDetailsTitle = (resource: Resource): string =>
|
||||
isPulseAgentPlatformResource(resource) ? 'Machine' : 'Host';
|
||||
|
||||
@@ -333,7 +276,12 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
drawer.sortedResourceTimeline().length > 0 ||
|
||||
drawer.resourceTimelineCount() > 0 ||
|
||||
Boolean(drawer.facetBundleError());
|
||||
const { activeAlerts } = useWebSocket();
|
||||
const webSocket = useWebSocket();
|
||||
const { activeAlerts } = webSocket;
|
||||
// An image's consumers and update verdict come from the containers that use
|
||||
// it, as on the images table. Only an image drawer reads the estate.
|
||||
const relatedResources = () =>
|
||||
resource().type === 'docker-image' ? (webSocket.state?.resources ?? []) : undefined;
|
||||
const alertsActivation = useAlertsActivation();
|
||||
const resourceAlerts = () =>
|
||||
getAlertsForUnifiedResource(resource(), activeAlerts, alertsActivation.detectionEnabled());
|
||||
@@ -391,8 +339,11 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
};
|
||||
|
||||
// Inventory follows the Proxmox drawers: anything wrong is lifted in front,
|
||||
// one facts line adds what the row does not say, and the rest waits under a
|
||||
// closed Details section rather than as a grid of equal-weight cards.
|
||||
// the object's own content (a pod's containers, a pool's or disk's health)
|
||||
// stays in view, one facts line adds what the row does not say, and the
|
||||
// rest waits under a closed Details section rather than as a grid of
|
||||
// equal-weight cards. Platform sections (TrueNAS, Kubernetes, vSphere) join
|
||||
// the same split, so a pool's ZFS errors or a cordoned node reach the front.
|
||||
const summarySections = useResourceSummarySections({
|
||||
get resource() {
|
||||
return resource();
|
||||
@@ -401,33 +352,42 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
get showPlatformId() {
|
||||
return showPlatformId();
|
||||
},
|
||||
get relatedResources() {
|
||||
return relatedResources();
|
||||
},
|
||||
content: 'all',
|
||||
});
|
||||
const inventory = () =>
|
||||
partitionDrawerExceptionRows(
|
||||
summarySections(),
|
||||
getAlertStatedRowLabels(
|
||||
const platformSections = () => [
|
||||
...drawer.trueNASDetailSections(),
|
||||
...drawer.kubernetesDetailSections(),
|
||||
...drawer.vmwareDetailSections(),
|
||||
];
|
||||
// One split per change, shared by the lifted rows, cards and Details.
|
||||
const inventory = createMemo(() =>
|
||||
buildResourceDrawerOverviewInventory({
|
||||
summarySections: summarySections(),
|
||||
platformSections: platformSections(),
|
||||
statedRowLabels: getAlertStatedRowLabels(
|
||||
getOwnAlertsForUnifiedResource(
|
||||
resource(),
|
||||
activeAlerts,
|
||||
alertsActivation.detectionEnabled(),
|
||||
),
|
||||
),
|
||||
);
|
||||
statedMessages: attentionItems().map((item) => item.message),
|
||||
}),
|
||||
);
|
||||
const exceptionRows = () => inventory().lifted;
|
||||
const contentCards = () => inventory().cards;
|
||||
const detailSections = () => inventory().details;
|
||||
const detailsSubtitle = () =>
|
||||
detailSections()
|
||||
.map((section) => section.label.toLowerCase())
|
||||
.join(', ');
|
||||
const facts = () => getResourceDrawerFacts(resource(), drawer.identityIpValues()[0]);
|
||||
const facts = () =>
|
||||
getResourceDrawerFacts(resource(), drawer.identityIpValues()[0], relatedResources());
|
||||
const hasPlatformDetails = () =>
|
||||
drawer.hasServiceDetails() ||
|
||||
drawer.hasVMwareDetails() ||
|
||||
drawer.hasTrueNASDetails() ||
|
||||
drawer.hasKubernetesDetails() ||
|
||||
drawer.hasHostDetails() ||
|
||||
drawer.hasInvestigationContext();
|
||||
drawer.hasServiceDetails() || drawer.hasHostDetails() || drawer.hasInvestigationContext();
|
||||
const availabilityChecks = () => [
|
||||
...(resource().availability ? [resource().availability!] : []),
|
||||
...(resource().availabilityChecks ?? []),
|
||||
@@ -472,6 +432,9 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={contentCards().length > 0}>
|
||||
<TechnicalDetailsSection dataTestId="resource-drawer-cards" sections={contentCards()} />
|
||||
</Show>
|
||||
<Show when={availabilityChecks().length > 0 && !availabilityHealthy()}>
|
||||
<div class="flex flex-wrap gap-3 *:min-w-[240px] *:flex-1">
|
||||
<AvailabilityProbeStatusCards
|
||||
@@ -517,8 +480,10 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Provider, service and host context share the one Details
|
||||
entrance rather than a second disclosure beside it. */}
|
||||
{/* Platform sections (TrueNAS, Kubernetes, vSphere) are already in
|
||||
the table above. Service, host and investigation context are
|
||||
cards and panels rather than rows, so they keep their own
|
||||
blocks, still behind this one Details entrance. */}
|
||||
<Show when={hasPlatformDetails()}>
|
||||
<section data-testid="resource-platform-details" class="space-y-2">
|
||||
<h4 class="text-[11px] font-medium uppercase tracking-wide text-muted">
|
||||
@@ -528,14 +493,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
data-testid="resource-support-sections"
|
||||
class="flex flex-wrap gap-3 *:flex-1 *:basis-[calc(50%-0.375rem)] *:min-w-[260px] *:max-w-full *:overflow-hidden"
|
||||
>
|
||||
<Show when={drawer.hasTrueNASDetails()}>
|
||||
<TrueNASDetailsDisclosure drawer={drawer} class="h-full" />
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.hasKubernetesDetails()}>
|
||||
<KubernetesDetailsDisclosure drawer={drawer} class="h-full" />
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.hasInvestigationContext()}>
|
||||
<SupportDisclosure
|
||||
title="Context"
|
||||
@@ -1103,10 +1060,6 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
</SupportDisclosure>
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.hasVMwareDetails()}>
|
||||
<VMwareDetailsDisclosure drawer={drawer} class="h-full" />
|
||||
</Show>
|
||||
|
||||
<Show when={drawer.hasHostDetails()}>
|
||||
<HostDetailsDisclosure resource={resource()} drawer={drawer} class="h-full" />
|
||||
</Show>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
|
||||
import { getDockerContainerUpdateAction } from '@/features/docker/dockerContainerUpdateAction';
|
||||
import { getDockerImageOperationalPresentation } from '@/features/docker/dockerImagePresentation';
|
||||
import {
|
||||
dockerContainerRestartAttentionLabel,
|
||||
dockerContainerRestartCountTitle,
|
||||
@@ -31,7 +32,10 @@ import {
|
||||
type DockerContainerStatePresentation,
|
||||
} from '@/features/docker/dockerContainerStatePresentation';
|
||||
import { formatInteger } from './resourceDetailMappers';
|
||||
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
|
||||
import {
|
||||
buildKubernetesControllerSection,
|
||||
getKubernetesPodFacts,
|
||||
} from './resourceDetailDrawerKubernetesModel';
|
||||
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
|
||||
|
||||
interface ResourceSummaryPresentationProps {
|
||||
@@ -40,6 +44,11 @@ interface ResourceSummaryPresentationProps {
|
||||
showPlatformId: boolean;
|
||||
content?: 'all' | 'overview' | 'technical';
|
||||
dataTestId?: string;
|
||||
/**
|
||||
* The estate's other resources. An image drawer reads its consumers and
|
||||
* their update checks from the containers here, as the images table does.
|
||||
*/
|
||||
relatedResources?: readonly Resource[];
|
||||
}
|
||||
|
||||
const dockerContainerMeta = (resource: Resource): NonNullable<Resource['docker']> | null => {
|
||||
@@ -161,7 +170,6 @@ const dockerSection = (
|
||||
updateNextSteps: () => JSX.Element,
|
||||
): DetailSection => {
|
||||
const labels = docker.labels ?? {};
|
||||
const labelEntries = Object.entries(labels);
|
||||
const createdAt = dockerTimestampMillis(docker.createdAt);
|
||||
const startedAt = dockerTimestampMillis(docker.startedAt);
|
||||
const finishedAt = dockerTimestampMillis(docker.finishedAt);
|
||||
@@ -263,33 +271,151 @@ const dockerSection = (
|
||||
blockWriteBytes !== null
|
||||
? makeDetailRow('Block I/O write', formatBytes(blockWriteBytes))
|
||||
: null,
|
||||
labelEntries.length > 0
|
||||
? richRow(
|
||||
'Labels',
|
||||
labelEntries.map(([key, value]) => (value ? `${key}: ${value}` : key)).join(', '),
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={labelEntries}>
|
||||
{([key, value]) => (
|
||||
<MetadataBadge
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
wrap
|
||||
title={value ? `${key}: ${value}` : key}
|
||||
>
|
||||
{key}
|
||||
<Show when={value}>: {value}</Show>
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
</div>,
|
||||
{ wrap: true },
|
||||
)
|
||||
: null,
|
||||
labelsRow(labels),
|
||||
]);
|
||||
|
||||
return { label: 'Container', rows, testId: 'resource-docker-container-section' };
|
||||
};
|
||||
|
||||
const labelsRow = (labels: Record<string, string> | undefined): DetailRow | null => {
|
||||
const entries = Object.entries(labels ?? {});
|
||||
if (entries.length === 0) return null;
|
||||
return richRow(
|
||||
'Labels',
|
||||
entries.map(([key, value]) => (value ? `${key}: ${value}` : key)).join(', '),
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={entries}>
|
||||
{([key, value]) => (
|
||||
<MetadataBadge size="xs" shape="rounded" wrap title={value ? `${key}: ${value}` : key}>
|
||||
{key}
|
||||
<Show when={value}>: {value}</Show>
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
</div>,
|
||||
{ wrap: true },
|
||||
);
|
||||
};
|
||||
|
||||
const badgeListRow = (label: string, values: readonly string[]): DetailRow | null => {
|
||||
const visible = values.map((value) => value.trim()).filter(Boolean);
|
||||
if (visible.length === 0) return null;
|
||||
return richRow(
|
||||
label,
|
||||
visible.join(', '),
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<For each={visible}>
|
||||
{(value) => (
|
||||
<MetadataBadge size="xs" shape="rounded" wrap title={value}>
|
||||
{value}
|
||||
</MetadataBadge>
|
||||
)}
|
||||
</For>
|
||||
</div>,
|
||||
{ wrap: true },
|
||||
);
|
||||
};
|
||||
|
||||
const relatedDockerContainers = (related: readonly Resource[] | undefined): Resource[] =>
|
||||
(related ?? []).filter((candidate) => candidate.type === 'app-container' && candidate.docker);
|
||||
|
||||
const dockerImageConsumersLabel = (count: number, summary: string): string => {
|
||||
if (count <= 0) return 'Unused';
|
||||
const noun = `${count} container${count === 1 ? '' : 's'}`;
|
||||
return summary && summary !== noun ? `${noun}: ${summary}` : noun;
|
||||
};
|
||||
|
||||
// An image's update verdict is its consumers' (the images table judges it the
|
||||
// same way). An available update is something to act on and a failed check
|
||||
// something wrong, so either is lifted in front; so is an image in use that
|
||||
// has never been checked, since nobody knows whether it is behind.
|
||||
const dockerImageSection = (
|
||||
resource: Resource,
|
||||
related: readonly Resource[] | undefined,
|
||||
): DetailSection | null => {
|
||||
if (resource.type !== 'docker-image') return null;
|
||||
const docker = resource.docker;
|
||||
if (!docker) return null;
|
||||
const operational = getDockerImageOperationalPresentation(
|
||||
resource,
|
||||
relatedDockerContainers(related),
|
||||
);
|
||||
const notChecked = operational.updateLabel === 'Not checked';
|
||||
const sizeBytes = dockerByteTotal(docker.sizeBytes);
|
||||
const sharedBytes = dockerByteTotal(docker.sharedSizeBytes);
|
||||
const rows = compactDetailRows([
|
||||
makeDetailRow('Update check', operational.updateLabel, {
|
||||
title: operational.updateDetail,
|
||||
tone:
|
||||
operational.updateTone === 'danger'
|
||||
? 'danger'
|
||||
: operational.updateTone === 'warning'
|
||||
? 'accent'
|
||||
: operational.updateTone === 'success'
|
||||
? 'success'
|
||||
: 'default',
|
||||
lift: operational.updateTone === 'warning' || (notChecked && operational.consumerCount > 0),
|
||||
}),
|
||||
makeDetailRow(
|
||||
'Used by',
|
||||
dockerImageConsumersLabel(operational.consumerCount, operational.consumerSummary),
|
||||
{ wrap: true },
|
||||
),
|
||||
sizeBytes !== null && sizeBytes > 0 ? makeDetailRow('Size', formatBytes(sizeBytes)) : null,
|
||||
sharedBytes !== null && sharedBytes > 0
|
||||
? makeDetailRow('Shared size', formatBytes(sharedBytes))
|
||||
: null,
|
||||
badgeListRow('Tags', docker.repoTags ?? []),
|
||||
makeDetailRow('Image ID', trimmedDockerValue(docker.imageId), {
|
||||
valueClass: 'break-all font-mono text-[10px]',
|
||||
wrap: true,
|
||||
}),
|
||||
makeDetailRow('Digests', (docker.repoDigests ?? []).map((value) => value.trim()).join(', '), {
|
||||
valueClass: 'break-all font-mono text-[10px]',
|
||||
wrap: true,
|
||||
}),
|
||||
labelsRow(docker.labels),
|
||||
]);
|
||||
return { label: 'Image', rows, testId: 'resource-docker-image-section' };
|
||||
};
|
||||
|
||||
const dockerVolumeConsumersLabel = (refCount: number | undefined): string | null => {
|
||||
if (typeof refCount !== 'number' || !Number.isFinite(refCount) || refCount < 0) return null;
|
||||
if (refCount === 0) return 'Unused';
|
||||
return `${refCount} container${refCount === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
const dockerVolumeSection = (resource: Resource, now: number): DetailSection | null => {
|
||||
if (resource.type !== 'docker-volume') return null;
|
||||
const docker = resource.docker;
|
||||
if (!docker) return null;
|
||||
const sizeBytes = dockerByteTotal(docker.sizeBytes);
|
||||
const createdAt = dockerTimestampMillis(docker.createdAt);
|
||||
const options = Object.entries(docker.options ?? {});
|
||||
const rows = compactDetailRows([
|
||||
makeDetailRow('Driver', trimmedDockerValue(docker.driver)),
|
||||
makeDetailRow('Scope', trimmedDockerValue(docker.scope)),
|
||||
makeDetailRow('Mountpoint', trimmedDockerValue(docker.mountpoint), {
|
||||
valueClass: 'break-all font-mono text-[10px]',
|
||||
wrap: true,
|
||||
}),
|
||||
// Docker reports -1 when it has not measured a volume's size.
|
||||
sizeBytes !== null ? makeDetailRow('Size', formatBytes(sizeBytes)) : null,
|
||||
makeDetailRow('Used by', dockerVolumeConsumersLabel(docker.refCount)),
|
||||
createdAt
|
||||
? makeDetailRow('Created', formatRelativeTime(createdAt, { now }), {
|
||||
title: new Date(createdAt).toLocaleString(),
|
||||
})
|
||||
: null,
|
||||
badgeListRow(
|
||||
'Options',
|
||||
options.map(([key, value]) => (value ? `${key}=${value}` : key)),
|
||||
),
|
||||
labelsRow(docker.labels),
|
||||
]);
|
||||
return { label: 'Volume', rows, testId: 'resource-docker-volume-section' };
|
||||
};
|
||||
|
||||
// A resource that is not reporting must say so in front, not only in Details:
|
||||
// offline is danger-toned (with when it was last seen), and a state Pulse
|
||||
// cannot vouch for is warning-toned, so the drawer lifts either.
|
||||
@@ -437,6 +563,10 @@ export const useResourceSummarySections = (
|
||||
props.content !== 'overview' && docker
|
||||
? dockerSection(props.resource, docker, now(), updateNextSteps)
|
||||
: null,
|
||||
props.content !== 'overview'
|
||||
? dockerImageSection(props.resource, props.relatedResources)
|
||||
: null,
|
||||
props.content !== 'overview' ? dockerVolumeSection(props.resource, now()) : null,
|
||||
props.content !== 'overview' ? buildKubernetesControllerSection(props.resource, now()) : null,
|
||||
props.content !== 'overview'
|
||||
? {
|
||||
@@ -449,15 +579,54 @@ export const useResourceSummarySections = (
|
||||
};
|
||||
};
|
||||
|
||||
// The images and volumes tables keep the name, size and verdict on a phone and
|
||||
// hide the host, consumers, driver and scope, so the facts line states those.
|
||||
const dockerInventoryFacts = (
|
||||
resource: Resource,
|
||||
related: readonly Resource[] | undefined,
|
||||
): string[] | null => {
|
||||
const docker = resource.docker;
|
||||
if (!docker) return null;
|
||||
const host = trimmedDockerValue(docker.hostname);
|
||||
if (resource.type === 'docker-image') {
|
||||
const operational = getDockerImageOperationalPresentation(
|
||||
resource,
|
||||
relatedDockerContainers(related),
|
||||
);
|
||||
return [
|
||||
operational.consumerCount > 0 ? `Used by ${operational.consumerSummary}` : 'Unused',
|
||||
host ? `Host ${host}` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
if (resource.type === 'docker-volume') {
|
||||
const driver = trimmedDockerValue(docker.driver);
|
||||
const scope = trimmedDockerValue(docker.scope);
|
||||
const consumers = dockerVolumeConsumersLabel(docker.refCount);
|
||||
return [
|
||||
[driver ? `${driver} driver` : '', scope ? `${scope} scope` : ''].filter(Boolean).join(', '),
|
||||
consumers === 'Unused' ? consumers : consumers ? `Used by ${consumers}` : '',
|
||||
host ? `Host ${host}` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The facts line under a resource drawer's charts: what the row does not
|
||||
* already say. A container states its image and Compose project and service
|
||||
* (a phone row hides the image); a host its
|
||||
* (a phone row hides the image); an image or volume its consumers and host; a
|
||||
* pod its namespace, owner and node; a host its
|
||||
* CPU count, the RAM and disk the row's percentages are of, and its primary
|
||||
* address. An offline resource states sizes only, not used amounts that would
|
||||
* read as live.
|
||||
*/
|
||||
export const getResourceDrawerFacts = (resource: Resource, primaryIp?: string): string[] => {
|
||||
export const getResourceDrawerFacts = (
|
||||
resource: Resource,
|
||||
primaryIp?: string,
|
||||
relatedResources?: readonly Resource[],
|
||||
): string[] => {
|
||||
const inventory = dockerInventoryFacts(resource, relatedResources);
|
||||
if (inventory) return inventory;
|
||||
const docker = dockerContainerMeta(resource);
|
||||
if (docker) {
|
||||
const labels = docker.labels;
|
||||
@@ -486,6 +655,7 @@ export const getResourceDrawerFacts = (resource: Resource, primaryIp?: string):
|
||||
};
|
||||
const cpus = resource.agent?.cpuCount ?? 0;
|
||||
return [
|
||||
...getKubernetesPodFacts(resource),
|
||||
cpus > 0 ? `${cpus} logical CPUs` : '',
|
||||
size(resource.memory, 'RAM'),
|
||||
size(resource.disk, 'disk'),
|
||||
|
||||
+18
-29
@@ -362,7 +362,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
expect(getByText('2001:db8::10/64')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces VMware read-only placement and signal context on the shared drawer path', async () => {
|
||||
it('surfaces VMware read-only placement and signal context on the shared drawer path', () => {
|
||||
const resource = baseResource({
|
||||
type: 'vm',
|
||||
name: 'app-01',
|
||||
@@ -589,62 +589,51 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByText, getByTestId, queryByText } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
const { getByTestId, queryByRole } = render(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
expect(getByTestId('resource-vmware-details-section')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText(
|
||||
'Lab VC · Read-only vCenter context · 2 snapshots · 1 vNIC · 1 disk · Hardware pending · Tools reboot requested · 1 alarm · 1 task',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(queryByText('Compute Cluster')).toBeNull();
|
||||
expect(queryByText('Create snapshot (success)')).toBeNull();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show vSphere' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Placement')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const section = getByTestId('resource-vmware-details-section');
|
||||
expect(within(section).getByText('State')).toBeInTheDocument();
|
||||
// vSphere sections are plain tables in the one Details disclosure, and
|
||||
// what needs a look (pending hardware upgrade, Tools reboot, an alarm) is
|
||||
// lifted to Worth checking rather than buried two toggles deep.
|
||||
expect(queryByRole('button', { name: 'Show vSphere' })).toBeNull();
|
||||
const lifted = within(getByTestId('resource-drawer-exceptions'));
|
||||
expect(lifted.getByText('Upgrade status')).toBeInTheDocument();
|
||||
expect(lifted.getByText('Pending')).toBeInTheDocument();
|
||||
expect(lifted.getByText('Guest reboot')).toBeInTheDocument();
|
||||
expect(lifted.getByText('Requested')).toBeInTheDocument();
|
||||
expect(lifted.getByText(/Host fan degraded/)).toBeInTheDocument();
|
||||
const section = getByTestId('resource-technical-details');
|
||||
expect(within(section).getByText('Placement')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Guest')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Virtual hardware')).toBeInTheDocument();
|
||||
expect(within(section).getByText('VMware Tools')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Virtual disks')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Network')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Signals')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Snapshot tree')).toBeInTheDocument();
|
||||
expect(within(section).getByText('vc.lab.local')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Compute Cluster')).toBeInTheDocument();
|
||||
expect(within(section).getAllByText('Compute Cluster').length).toBeGreaterThan(0);
|
||||
expect(within(section).getByText('Cluster services')).toBeInTheDocument();
|
||||
expect(within(section).getByText('HA enabled · DRS disabled')).toBeInTheDocument();
|
||||
expect(within(section).getByText('esxi-01.lab.local')).toBeInTheDocument();
|
||||
expect(within(section).getByText('ubuntu64Guest')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Hardware version')).toBeInTheDocument();
|
||||
expect(within(section).getByText('VMX 20')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Upgrade status')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Pending')).toBeInTheDocument();
|
||||
expect(within(section).getByText('CPU topology')).toBeInTheDocument();
|
||||
expect(within(section).getByText('4 vCPU · 2 cores/socket')).toBeInTheDocument();
|
||||
expect(within(section).getByText('OK (green)')).toBeInTheDocument();
|
||||
// Boot order, install type, and the default-case Version status row no
|
||||
// longer render: they live in the raw API payload and the drawer keeps
|
||||
// hardware/tools sections focused on actionable operator state.
|
||||
expect(within(section).queryByText('Boot order')).toBeNull();
|
||||
expect(within(section).queryByText('Open VM Tools')).toBeNull();
|
||||
expect(within(section).getByText('Run state')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Running')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Guest reboot')).toBeInTheDocument();
|
||||
expect(within(section).getByText('Requested')).toBeInTheDocument();
|
||||
// Lifted rows move rather than repeat.
|
||||
expect(within(section).queryByText('Upgrade status')).toBeNull();
|
||||
expect(within(section).queryByText('Guest reboot')).toBeNull();
|
||||
expect(within(section).getByText('Hard disk 1')).toBeInTheDocument();
|
||||
expect(within(section).getByText(/SCSI 0:1 · 100 GB · shared-vsan/)).toBeInTheDocument();
|
||||
expect(within(section).getByText('Network adapter 1')).toBeInTheDocument();
|
||||
expect(within(section).getByText(/VMXNET3 · VM Network/)).toBeInTheDocument();
|
||||
expect(within(section).getByText(/Create snapshot \(success\)/)).toBeInTheDocument();
|
||||
expect(within(section).getByText(/Host fan degraded/)).toBeInTheDocument();
|
||||
expect(within(section).getByText('2 snapshots')).toBeInTheDocument();
|
||||
expect(within(section).getByText('pre-upgrade')).toBeInTheDocument();
|
||||
expect(within(section).getByText('- post-migration-checkpoint')).toBeInTheDocument();
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, within } from '@solidjs/testing-library';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
|
||||
// The estate the drawer reads an image's consumers from.
|
||||
const estate: Resource[] = [];
|
||||
|
||||
vi.mock('@/contexts/appRuntime', () => ({
|
||||
useWebSocket: () => ({
|
||||
activeAlerts: {},
|
||||
state: { pmg: [] as unknown[], resources: estate },
|
||||
connected: () => true,
|
||||
initialDataReceived: () => true,
|
||||
reconnecting: () => false,
|
||||
reconnect: vi.fn(),
|
||||
}),
|
||||
useDarkMode: () => () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/resources', () => ({
|
||||
ResourceAPI: {
|
||||
getFacetBundle: vi.fn().mockResolvedValue({
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/ai', () => ({
|
||||
AIAPI: {
|
||||
getResourceIntelligence: vi.fn().mockResolvedValue({
|
||||
resource_id: 'resource-1',
|
||||
health: {
|
||||
score: 100,
|
||||
grade: 'A',
|
||||
trend: 'stable',
|
||||
factors: [],
|
||||
prediction: 'Stable',
|
||||
},
|
||||
recent_changes: [],
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
correlations: [],
|
||||
note_count: 0,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const baseResource = (overrides: Partial<Resource>): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'vm',
|
||||
name: 'resource-1',
|
||||
displayName: 'resource-1',
|
||||
platformId: 'platform-1',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const facts = (getByTestId: (id: string) => HTMLElement) =>
|
||||
getByTestId('resource-drawer-facts').textContent ?? '';
|
||||
|
||||
// Problems in platform sections reach "Worth checking", an object's own content
|
||||
// stays in view, and the facts line carries what a phone row hides.
|
||||
describe('ResourceDetailDrawer platform content', () => {
|
||||
afterEach(() => {
|
||||
estate.length = 0;
|
||||
});
|
||||
|
||||
it('describes a Docker image and lifts an update its consumers report', () => {
|
||||
estate.push(
|
||||
baseResource({
|
||||
id: 'container-web',
|
||||
type: 'app-container',
|
||||
name: 'web',
|
||||
docker: {
|
||||
image: 'nginx:1.27',
|
||||
updateStatus: { updateAvailable: true, currentDigest: 'sha256:aaa' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const image = baseResource({
|
||||
id: 'image-nginx',
|
||||
type: 'docker-image',
|
||||
name: 'nginx:1.27',
|
||||
docker: {
|
||||
hostname: 'docker-1',
|
||||
image: 'nginx:1.27',
|
||||
imageId: 'sha256:0123456789abcdef',
|
||||
repoTags: ['nginx:1.27', 'nginx:stable'],
|
||||
repoDigests: ['nginx@sha256:fedcba9876543210'],
|
||||
sizeBytes: 190 * 1024 ** 2,
|
||||
imageContainers: 1,
|
||||
labels: { maintainer: 'NGINX Docker Maintainers' },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = render(() => <ResourceDetailDrawer resource={image} />);
|
||||
|
||||
const lifted = within(getByTestId('resource-drawer-exceptions'));
|
||||
expect(lifted.getByText('Update check')).toBeInTheDocument();
|
||||
expect(lifted.getByText('Update available')).toBeInTheDocument();
|
||||
expect(facts(getByTestId)).toContain('Used by web');
|
||||
expect(facts(getByTestId)).toContain('Host docker-1');
|
||||
|
||||
const section = within(getByTestId('resource-docker-image-section'));
|
||||
expect(section.getByText('1 container: web')).toBeInTheDocument();
|
||||
expect(section.getByText('nginx:stable')).toBeInTheDocument();
|
||||
expect(section.getByText('sha256:0123456789abcdef')).toBeInTheDocument();
|
||||
expect(section.getByText('nginx@sha256:fedcba9876543210')).toBeInTheDocument();
|
||||
expect(section.getByText('190 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags an image in use that has never been checked, but not an unused one', () => {
|
||||
const image = (imageContainers: number) =>
|
||||
baseResource({
|
||||
id: `image-${imageContainers}`,
|
||||
type: 'docker-image',
|
||||
name: 'local/app:dev',
|
||||
docker: { image: 'local/app:dev', imageContainers },
|
||||
});
|
||||
|
||||
const used = render(() => <ResourceDetailDrawer resource={image(2)} />);
|
||||
expect(
|
||||
within(used.getByTestId('resource-drawer-exceptions')).getByText('Not checked'),
|
||||
).toBeInTheDocument();
|
||||
used.unmount();
|
||||
|
||||
const unused = render(() => <ResourceDetailDrawer resource={image(0)} />);
|
||||
expect(unused.queryByTestId('resource-drawer-exceptions')).toBeNull();
|
||||
expect(facts(unused.getByTestId)).toContain('Unused');
|
||||
});
|
||||
|
||||
it('describes a Docker volume with its mountpoint in full', () => {
|
||||
const volume = baseResource({
|
||||
id: 'volume-data',
|
||||
type: 'docker-volume',
|
||||
name: 'app_data',
|
||||
docker: {
|
||||
hostname: 'docker-1',
|
||||
volumeName: 'app_data',
|
||||
driver: 'local',
|
||||
scope: 'local',
|
||||
mountpoint: '/var/lib/docker/volumes/app_data/_data',
|
||||
sizeBytes: 2 * 1024 ** 3,
|
||||
refCount: 2,
|
||||
labels: { 'com.docker.compose.project': 'app' },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = render(() => <ResourceDetailDrawer resource={volume} />);
|
||||
|
||||
expect(facts(getByTestId)).toContain('local driver, local scope');
|
||||
expect(facts(getByTestId)).toContain('Used by 2 containers');
|
||||
const section = within(getByTestId('resource-docker-volume-section'));
|
||||
const mountpoint = section.getByText('/var/lib/docker/volumes/app_data/_data');
|
||||
expect(mountpoint.className).toContain('whitespace-normal');
|
||||
expect(section.getByText('2 containers')).toBeInTheDocument();
|
||||
expect(section.getByText(/com\.docker\.compose\.project/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a pod's containers in front and lifts the one that is not ready", () => {
|
||||
const pod = baseResource({
|
||||
id: 'pod-web',
|
||||
type: 'pod',
|
||||
name: 'web-7d9-abc',
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
ownerKind: 'ReplicaSet',
|
||||
ownerName: 'web-7d9',
|
||||
nodeName: 'worker-2',
|
||||
podContainers: [
|
||||
{ name: 'api', ready: true, state: 'running', image: 'ghcr.io/acme/api:1.2.3' },
|
||||
{
|
||||
name: 'sidecar',
|
||||
ready: false,
|
||||
state: 'waiting',
|
||||
reason: 'CrashLoopBackOff',
|
||||
restartCount: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = render(() => <ResourceDetailDrawer resource={pod} />);
|
||||
|
||||
const cards = getByTestId('resource-drawer-cards');
|
||||
expect(cards.closest('[data-testid="resource-technical-details"]')).toBeNull();
|
||||
expect(within(cards).getByText('Containers (2)')).toBeInTheDocument();
|
||||
expect(within(cards).getByText(/ghcr\.io\/acme\/api:1\.2\.3/)).toBeInTheDocument();
|
||||
expect(
|
||||
within(getByTestId('resource-drawer-exceptions')).getByText('sidecar'),
|
||||
).toBeInTheDocument();
|
||||
expect(facts(getByTestId)).toContain('Namespace apps');
|
||||
expect(facts(getByTestId)).toContain('ReplicaSet web-7d9');
|
||||
expect(facts(getByTestId)).toContain('Node worker-2');
|
||||
});
|
||||
|
||||
it('lifts a cordoned node and a Deployment short of its target', () => {
|
||||
const node = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={baseResource({
|
||||
id: 'node-1',
|
||||
type: 'k8s-node',
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: { osImage: 'Ubuntu 24.04 LTS', unschedulable: true },
|
||||
})}
|
||||
/>
|
||||
));
|
||||
expect(
|
||||
within(node.getByTestId('resource-drawer-exceptions')).getByText('Cordoned (unschedulable)'),
|
||||
).toBeInTheDocument();
|
||||
node.unmount();
|
||||
|
||||
const deployment = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={baseResource({
|
||||
id: 'deploy-web',
|
||||
type: 'k8s-deployment',
|
||||
platformType: 'kubernetes',
|
||||
kubernetes: { desiredReplicas: 3, readyReplicas: 1, availableReplicas: 1 },
|
||||
})}
|
||||
/>
|
||||
));
|
||||
const lifted = within(deployment.getByTestId('resource-drawer-exceptions'));
|
||||
expect(lifted.getAllByText('1 of 3')).toHaveLength(2);
|
||||
expect(lifted.getByText('0 of 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lifts an inaccessible vSphere datastore out of Details', () => {
|
||||
const { getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={baseResource({
|
||||
id: 'datastore-1',
|
||||
type: 'storage',
|
||||
platformType: 'vmware-vsphere',
|
||||
vmware: {
|
||||
entityType: 'datastore',
|
||||
datastoreType: 'VMFS',
|
||||
datastoreAccessible: false,
|
||||
maintenanceMode: 'normal',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
));
|
||||
const lifted = within(getByTestId('resource-drawer-exceptions'));
|
||||
expect(lifted.getByText('Accessible')).toBeInTheDocument();
|
||||
expect(lifted.queryByText('Maintenance')).toBeNull();
|
||||
expect(within(getByTestId('resource-technical-details')).getByText('VMFS')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+112
-146
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, within } from '@solidjs/testing-library';
|
||||
import { render, within } from '@solidjs/testing-library';
|
||||
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
|
||||
@@ -61,15 +61,19 @@ const baseResource = (overrides: Partial<Resource>): Resource =>
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
const expandPlatformDetails = (getByTestId: (id: string) => HTMLElement): void => {
|
||||
// Platform context sits inside the Overview's one Details disclosure.
|
||||
const details = getByTestId('resource-technical-details') as HTMLDetailsElement;
|
||||
details.open = true;
|
||||
fireEvent(details, new Event('toggle'));
|
||||
const details = (getByTestId: (id: string) => HTMLElement) =>
|
||||
within(getByTestId('resource-technical-details'));
|
||||
|
||||
const worthChecking = (queryByTestId: (id: string) => HTMLElement | null) => {
|
||||
const list = queryByTestId('resource-drawer-exceptions');
|
||||
return list ? within(list) : null;
|
||||
};
|
||||
|
||||
// TrueNAS sections sit in the Overview's one Details disclosure as plain
|
||||
// tables (no second "Show TrueNAS" toggle), their problems are lifted to
|
||||
// "Worth checking", and a pool's or disk's health stays in view as a card.
|
||||
describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
it('opens native TrueNAS system detail immediately for inline platform rows', () => {
|
||||
it("lifts a TrueNAS system's storage problems and keeps the rest under Details", () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-system-1',
|
||||
type: 'agent',
|
||||
@@ -92,33 +96,29 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={resource}
|
||||
presentation="table-row"
|
||||
initialShowTrueNASDetails
|
||||
/>
|
||||
const { getByTestId, queryByRole, queryByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} presentation="table-row" />
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
|
||||
expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
|
||||
const trueNASSection = getByTestId('resource-truenas-details-section');
|
||||
const summary = getByTestId('resource-technical-details');
|
||||
expect(queryByRole('button', { name: 'Show TrueNAS' })).toBeNull();
|
||||
const lifted = worthChecking(queryByTestId);
|
||||
expect(lifted?.getByText('ZFS pool archive is DEGRADED')).toBeInTheDocument();
|
||||
expect(
|
||||
trueNASSection.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_PRECEDING,
|
||||
).toBeTruthy();
|
||||
const section = within(trueNASSection);
|
||||
expect(section.getByText('System')).toBeInTheDocument();
|
||||
expect(section.getByText('Storage Health')).toBeInTheDocument();
|
||||
expect(section.getAllByText('Services').length).toBeGreaterThan(1);
|
||||
expect(section.getByText('TrueNAS-SCALE-24.10.2')).toBeInTheDocument();
|
||||
expect(section.getByText('SMB, NFS, SSH, SMART')).toBeInTheDocument();
|
||||
expect(
|
||||
section.getByText('Snapshots are current but replication is degraded'),
|
||||
lifted?.getByText('Snapshots are current but replication is degraded'),
|
||||
).toBeInTheDocument();
|
||||
// An enabled service that is stopped is worth a look.
|
||||
expect(lifted?.getByText('Stopped')).toBeInTheDocument();
|
||||
expect(lifted?.getByText('1 service')).toBeInTheDocument();
|
||||
|
||||
const inventory = details(getByTestId);
|
||||
expect(inventory.getByText('System')).toBeInTheDocument();
|
||||
expect(inventory.getByText('TrueNAS-SCALE-24.10.2')).toBeInTheDocument();
|
||||
expect(inventory.getByText('SMB, NFS, SSH, SMART')).toBeInTheDocument();
|
||||
// Lifted rows move rather than repeat.
|
||||
expect(inventory.queryByText('ZFS pool archive is DEGRADED')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders native TrueNAS VM detail only after the TrueNAS section is expanded', () => {
|
||||
it('keeps native TrueNAS VM detail under Details', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-vm-1',
|
||||
type: 'vm',
|
||||
@@ -139,29 +139,23 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getByRole, getByTestId, getAllByText, queryByText } = render(() => (
|
||||
const { getByTestId, queryByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
|
||||
expect(getAllByText('TrueNAS').length).toBeGreaterThan(0);
|
||||
expect(getByText('Running, 4 vCPU, 8.00 GB, 3 devices')).toBeInTheDocument();
|
||||
expect(queryByText('Bootloader')).toBeNull();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show TrueNAS' }));
|
||||
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(section.getByText('Compute')).toBeInTheDocument();
|
||||
expect(section.getByText('Runtime')).toBeInTheDocument();
|
||||
expect(section.getByText('Devices')).toBeInTheDocument();
|
||||
expect(section.getByText('Flags')).toBeInTheDocument();
|
||||
expect(section.getByText('Bootloader')).toBeInTheDocument();
|
||||
expect(section.getByText('UEFI')).toBeInTheDocument();
|
||||
expect(section.getByText('Secure boot')).toBeInTheDocument();
|
||||
expect(section.getAllByText('Enabled').length).toBeGreaterThan(1);
|
||||
expect(queryByTestId('resource-drawer-exceptions')).toBeNull();
|
||||
expect((getByTestId('resource-technical-details') as HTMLDetailsElement).open).toBe(false);
|
||||
const inventory = details(getByTestId);
|
||||
expect(inventory.getByText('Compute')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Runtime')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Devices')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Flags')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Bootloader')).toBeInTheDocument();
|
||||
expect(inventory.getByText('UEFI')).toBeInTheDocument();
|
||||
expect(inventory.getAllByText('Enabled').length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('renders native TrueNAS app detail without Docker runtime controls', () => {
|
||||
it('lifts an available TrueNAS app update without Docker runtime controls', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-app-1',
|
||||
type: 'app-container',
|
||||
@@ -189,82 +183,23 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getByRole, getByTestId, queryByText } = render(() => (
|
||||
const { getByTestId, queryByTestId, queryByText } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
|
||||
expect(getByText('Running, 2 containers, 1 port, 1 update')).toBeInTheDocument();
|
||||
expect(queryByText('Docker runtime')).toBeNull();
|
||||
const lifted = worthChecking(queryByTestId);
|
||||
expect(lifted?.getByText('App updates')).toBeInTheDocument();
|
||||
expect(lifted?.getByText('Available')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show TrueNAS' }));
|
||||
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(section.getByText('App')).toBeInTheDocument();
|
||||
expect(section.getByText('Networking')).toBeInTheDocument();
|
||||
expect(section.getByText('Storage')).toBeInTheDocument();
|
||||
expect(section.getByText('0.0.0.0:30443 -> 443/tcp')).toBeInTheDocument();
|
||||
expect(section.getByText('/mnt/tank/apps/nextcloud -> /data')).toBeInTheDocument();
|
||||
const inventory = details(getByTestId);
|
||||
expect(inventory.getByText('App')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Networking')).toBeInTheDocument();
|
||||
expect(inventory.getByText('0.0.0.0:30443 -> 443/tcp')).toBeInTheDocument();
|
||||
expect(inventory.getByText('/mnt/tank/apps/nextcloud -> /data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can open native TrueNAS detail immediately for inline platform rows', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-app-1',
|
||||
type: 'app-container',
|
||||
displayName: 'nextcloud',
|
||||
truenas: {
|
||||
app: {
|
||||
name: 'nextcloud',
|
||||
state: 'RUNNING',
|
||||
humanVersion: '29.0.0',
|
||||
containerCount: 2,
|
||||
upgradeAvailable: true,
|
||||
imageUpdatesAvailable: false,
|
||||
usedPorts: [
|
||||
{
|
||||
containerPort: 443,
|
||||
protocol: 'tcp',
|
||||
hostPorts: [{ hostIp: '0.0.0.0', hostPort: 30443 }],
|
||||
},
|
||||
],
|
||||
volumes: [{ source: '/mnt/tank/apps/nextcloud', destination: '/data' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={resource}
|
||||
presentation="table-row"
|
||||
initialShowTrueNASDetails
|
||||
/>
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
|
||||
const summary = getByTestId('resource-technical-details');
|
||||
const technicalDetails = getByTestId('resource-technical-details') as HTMLDetailsElement;
|
||||
technicalDetails.open = true;
|
||||
fireEvent(technicalDetails, new Event('toggle'));
|
||||
expect(getByTestId('resource-technical-summary-section').querySelector('table')).toBeTruthy();
|
||||
expect(() => getByTestId('resource-current-state-section')).toThrow();
|
||||
expect(getByTestId('resource-identity-section').tagName).toBe('TBODY');
|
||||
expect(summary.querySelector('[class*="shadow-xs"]')).not.toBeNull();
|
||||
expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
|
||||
const trueNASSection = getByTestId('resource-truenas-details-section');
|
||||
expect(
|
||||
trueNASSection.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_PRECEDING,
|
||||
).toBeTruthy();
|
||||
const section = within(trueNASSection);
|
||||
expect(trueNASSection.querySelector('table')).toBeTruthy();
|
||||
expect(
|
||||
getByTestId('resource-truenas-details-section').querySelector('[class*="cyan"]'),
|
||||
).toBeNull();
|
||||
expect(section.getByText('App')).toBeInTheDocument();
|
||||
expect(section.getByText('0.0.0.0:30443 -> 443/tcp')).toBeInTheDocument();
|
||||
expect(section.getByText('/mnt/tank/apps/nextcloud -> /data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders native TrueNAS share detail from SMB and NFS inventory', () => {
|
||||
it('keeps native TrueNAS share detail under Details', () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-share-1',
|
||||
type: 'network-share',
|
||||
@@ -285,25 +220,18 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByText, getByRole, getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
const { getByTestId } = render(() => <ResourceDetailDrawer resource={resource} />);
|
||||
|
||||
expect(getByText('SMB, Enabled, tank/media, Read/write')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByRole('button', { name: 'Show TrueNAS' }));
|
||||
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(section.getByText('Share')).toBeInTheDocument();
|
||||
expect(section.getByText('Access')).toBeInTheDocument();
|
||||
expect(section.getByText('Clients')).toBeInTheDocument();
|
||||
expect(section.getByText('/mnt/tank/media')).toBeInTheDocument();
|
||||
expect(section.getByText('Read/write')).toBeInTheDocument();
|
||||
expect(section.getByText('media')).toBeInTheDocument();
|
||||
const inventory = details(getByTestId);
|
||||
expect(inventory.getByText('Share')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Access')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Clients')).toBeInTheDocument();
|
||||
expect(inventory.getByText('/mnt/tank/media')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Read/write')).toBeInTheDocument();
|
||||
expect(inventory.getByText('media')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens native TrueNAS storage detail immediately for inline storage rows', () => {
|
||||
it("shows a degraded pool's health in front and its ZFS errors under Worth checking", () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-pool-1',
|
||||
type: 'storage',
|
||||
@@ -322,6 +250,8 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
platform: 'truenas',
|
||||
protection: 'zfs',
|
||||
zfsPoolState: 'DEGRADED',
|
||||
zfsReadErrors: 1,
|
||||
zfsChecksumErrors: 2,
|
||||
risk: {
|
||||
level: 'warning',
|
||||
reasons: [
|
||||
@@ -338,22 +268,58 @@ describe('ResourceDetailDrawer TrueNAS details', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId } = render(() => (
|
||||
<ResourceDetailDrawer
|
||||
resource={resource}
|
||||
presentation="table-row"
|
||||
initialShowTrueNASDetails
|
||||
/>
|
||||
const { getByTestId, queryByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} presentation="table-row" />
|
||||
));
|
||||
expandPlatformDetails(getByTestId);
|
||||
|
||||
expect(getByRole('button', { name: 'Hide TrueNAS' })).toBeInTheDocument();
|
||||
const section = within(getByTestId('resource-truenas-details-section'));
|
||||
expect(getByTestId('resource-truenas-details-section').querySelector('table')).toBeTruthy();
|
||||
expect(section.getByText('Storage')).toBeInTheDocument();
|
||||
expect(section.getByText('Capacity')).toBeInTheDocument();
|
||||
expect(section.getByText('Health')).toBeInTheDocument();
|
||||
expect(section.getByText('ZFS')).toBeInTheDocument();
|
||||
expect(section.getAllByText('ZFS pool archive is DEGRADED')).toHaveLength(3);
|
||||
// The pool's own summary is stated once, by the alerts block.
|
||||
expect(
|
||||
within(getByTestId('drawer-attention-section')).getByText('ZFS pool archive is DEGRADED'),
|
||||
).toBeInTheDocument();
|
||||
const lifted = worthChecking(queryByTestId);
|
||||
expect(lifted?.getByText('ZFS errors')).toBeInTheDocument();
|
||||
expect(lifted?.getByText('Read 1 · Write 0 · Checksum 2')).toBeInTheDocument();
|
||||
expect(lifted?.queryByText('ZFS pool archive is DEGRADED')).toBeNull();
|
||||
|
||||
// Pool health is a content card outside the closed Details.
|
||||
const cards = getByTestId('resource-drawer-cards');
|
||||
expect(cards.closest('[data-testid="resource-technical-details"]')).toBeNull();
|
||||
expect(within(cards).getByText('Health')).toBeInTheDocument();
|
||||
expect(within(cards).getByText('DEGRADED')).toBeInTheDocument();
|
||||
|
||||
const inventory = details(getByTestId);
|
||||
expect(inventory.getByText('Storage')).toBeInTheDocument();
|
||||
expect(inventory.getByText('Capacity')).toBeInTheDocument();
|
||||
expect(inventory.getByText('ZFS')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a disk's health and SMART readings in front", () => {
|
||||
const resource = baseResource({
|
||||
id: 'truenas-disk-1',
|
||||
type: 'physical_disk',
|
||||
displayName: 'sdc',
|
||||
platformScopes: ['truenas'],
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sdc',
|
||||
model: 'WD Red Pro',
|
||||
serial: 'WD-WX12A3456',
|
||||
diskType: 'sata',
|
||||
sizeBytes: 24 * 1024 ** 4,
|
||||
health: 'PASSED',
|
||||
smart: { powerOnHours: 10_240, reallocatedSectors: 4 },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
|
||||
const cards = within(getByTestId('resource-drawer-cards'));
|
||||
// The Health card and its Health row.
|
||||
expect(cards.getAllByText('Health')).toHaveLength(2);
|
||||
expect(cards.getByText('SMART')).toBeInTheDocument();
|
||||
expect(cards.getByText('Passed')).toBeInTheDocument();
|
||||
expect(worthChecking(queryByTestId)?.getByText('Reallocated')).toBeInTheDocument();
|
||||
expect(details(getByTestId).getByText('WD-WX12A3456')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -66,6 +66,7 @@ describe('containerRow (via Containers section)', () => {
|
||||
value: 'unknown · not ready · ghcr.io/acme/api:1.2.3',
|
||||
title: 'unknown · not ready · ghcr.io/acme/api:1.2.3',
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +85,7 @@ describe('containerRow (via Containers section)', () => {
|
||||
value: 'running · ready · ghcr.io/acme/api:1.2.3',
|
||||
title: 'running · ready · ghcr.io/acme/api:1.2.3',
|
||||
tone: 'default',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +103,7 @@ describe('containerRow (via Containers section)', () => {
|
||||
value: 'waiting (ImagePullBackOff) · not ready',
|
||||
title: 'waiting (ImagePullBackOff) · not ready: manifest not found',
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -315,6 +318,7 @@ describe('buildKubernetesDetailSections', () => {
|
||||
expect(sectionByLabel(node, 'Kubernetes node')?.rows).toContainEqual({
|
||||
label: 'OS image',
|
||||
value: 'Flatcar 3600.0.0',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+274
-12
@@ -6,8 +6,10 @@ import {
|
||||
buildKubernetesControllerSection,
|
||||
buildKubernetesDetailSections,
|
||||
buildKubernetesDetailsSummary,
|
||||
getKubernetesPodFacts,
|
||||
hasKubernetesDetailSections,
|
||||
} from '../resourceDetailDrawerKubernetesModel';
|
||||
import { isDrawerContentCard } from '../resourceDetailDrawerOverviewModel';
|
||||
|
||||
const makeResource = ({
|
||||
id,
|
||||
@@ -99,8 +101,8 @@ describe('buildKubernetesDetailSections', () => {
|
||||
|
||||
const section = sectionByLabel(node, 'Kubernetes node');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'OS image', value: 'Ubuntu 22.04.5 LTS' },
|
||||
{ label: 'Kernel', value: '6.6.32-1-lts' },
|
||||
{ label: 'OS image', value: 'Ubuntu 22.04.5 LTS', wrap: true },
|
||||
{ label: 'Kernel', value: '6.6.32-1-lts', wrap: true },
|
||||
{ label: 'Architecture', value: 'amd64' },
|
||||
{ label: 'Capacity', value: '24 cores / 64.0 GB / 110 pods' },
|
||||
{ label: 'Allocatable', value: '21 cores / 60.0 GB / 105 pods' },
|
||||
@@ -117,6 +119,7 @@ describe('buildKubernetesDetailSections', () => {
|
||||
expect(sectionByLabel(mergedNode, 'Kubernetes node')?.rows).toContainEqual({
|
||||
label: 'OS image',
|
||||
value: 'Fedora CoreOS 40',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,8 +137,8 @@ describe('buildKubernetesDetailSections', () => {
|
||||
|
||||
const section = sectionByLabel(cluster, 'Cluster');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'API server', value: 'https://prod.k8s.local:6443' },
|
||||
{ label: 'Context', value: 'prod-admin' },
|
||||
{ label: 'API server', value: 'https://prod.k8s.local:6443', wrap: true },
|
||||
{ label: 'Context', value: 'prod-admin', wrap: true },
|
||||
{ label: 'Agent version', value: '0.9.1' },
|
||||
{ label: 'Pending uninstall', value: 'Yes', tone: 'warning' },
|
||||
]);
|
||||
@@ -231,8 +234,8 @@ describe('buildKubernetesControllerSection', () => {
|
||||
{ label: 'Active', value: '1' },
|
||||
{ label: 'Succeeded', value: '8' },
|
||||
{ label: 'Failed', value: '2', tone: 'danger' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
{ label: 'Namespace', value: 'batch', wrap: true },
|
||||
{ label: 'Cluster', value: 'prod', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -296,8 +299,8 @@ describe('buildKubernetesControllerSection', () => {
|
||||
{ label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true },
|
||||
{ label: 'Suspended', value: 'Yes', tone: 'warning' },
|
||||
{ label: 'Active', value: '0' },
|
||||
{ label: 'Namespace', value: 'batch' },
|
||||
{ label: 'Cluster', value: 'prod' },
|
||||
{ label: 'Namespace', value: 'batch', wrap: true },
|
||||
{ label: 'Cluster', value: 'prod', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -319,13 +322,14 @@ describe('buildKubernetesControllerSection', () => {
|
||||
);
|
||||
expect(statefulSet?.label).toBe('StatefulSet');
|
||||
expect(statefulSet?.rows).toEqual([
|
||||
{ label: 'Service', value: 'checkout-headless' },
|
||||
{ label: 'Service', value: 'checkout-headless', wrap: true },
|
||||
{ label: 'Target', value: '3 pods' },
|
||||
{ label: 'Current', value: '3' },
|
||||
{ label: 'Ready', value: '2' },
|
||||
// Fewer ready pods than the target is a shortfall the Overview lifts.
|
||||
{ label: 'Ready', value: '2 of 3', tone: 'warning' },
|
||||
{ label: 'Available', value: '2' },
|
||||
{ label: 'Updated', value: '2' },
|
||||
{ label: 'Namespace', value: 'apps' },
|
||||
{ label: 'Namespace', value: 'apps', wrap: true },
|
||||
]);
|
||||
|
||||
const daemonSet = buildKubernetesControllerSection(
|
||||
@@ -353,7 +357,7 @@ describe('buildKubernetesControllerSection', () => {
|
||||
{ label: 'Updated', value: '5' },
|
||||
{ label: 'Unavailable', value: '1', tone: 'warning' },
|
||||
{ label: 'Misscheduled', value: '0', tone: 'default' },
|
||||
{ label: 'Namespace', value: 'observability' },
|
||||
{ label: 'Namespace', value: 'observability', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -379,3 +383,261 @@ describe('buildKubernetesControllerSection', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kubernetes network, storage and scaling sections', () => {
|
||||
it('states a Deployment shortfall as "n of target" so the Overview lifts it', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout',
|
||||
type: 'k8s-deployment',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
desiredReplicas: 3,
|
||||
updatedReplicas: 3,
|
||||
readyReplicas: 2,
|
||||
observedGeneration: 4,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('Deployment');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Target', value: '3 pods' },
|
||||
{ label: 'Updated', value: '3' },
|
||||
{ label: 'Ready', value: '2 of 3', tone: 'warning' },
|
||||
// Kubernetes omits a zero count: none available.
|
||||
{ label: 'Available', value: '0 of 3', tone: 'warning' },
|
||||
{ label: 'Observed generation', value: '4' },
|
||||
{ label: 'Namespace', value: 'apps', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads a settled Deployment as plain counts with nothing to lift', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'checkout',
|
||||
type: 'k8s-deployment',
|
||||
kubernetes: {
|
||||
desiredReplicas: 2,
|
||||
updatedReplicas: 2,
|
||||
readyReplicas: 2,
|
||||
availableReplicas: 2,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.rows.some((row) => row.tone === 'warning')).toBe(false);
|
||||
expect(section?.rows.find((row) => row.label === 'Ready')?.value).toBe('2');
|
||||
});
|
||||
|
||||
it('describes an autoscaler and flags one held at its maximum', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'web-hpa',
|
||||
type: 'k8s-horizontal-pod-autoscaler',
|
||||
kubernetes: {
|
||||
targetKind: 'Deployment',
|
||||
targetName: 'web',
|
||||
minReplicas: 2,
|
||||
maxReplicas: 5,
|
||||
currentReplicas: 5,
|
||||
desiredReplicas: 5,
|
||||
metricTypes: ['Resource', 'Pods'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('Autoscaler');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Target', value: 'Deployment web', wrap: true },
|
||||
{ label: 'Range', value: '2 to 5 pods' },
|
||||
{ label: 'Current', value: '5 pods' },
|
||||
{ label: 'Desired', value: '5 pods' },
|
||||
{ label: 'Scaling', value: 'At its maximum of 5 pods', tone: 'warning' },
|
||||
{ label: 'Metrics', value: 'Resource, Pods', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a Service address, ports and selector in full', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'web',
|
||||
type: 'k8s-service',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
clusterName: 'prod',
|
||||
serviceType: 'NodePort',
|
||||
clusterIp: '10.96.12.4',
|
||||
externalIps: ['203.0.113.7'],
|
||||
servicePorts: [
|
||||
{ name: 'http', port: 80, targetPort: '8080', protocol: 'TCP', nodePort: 30080 },
|
||||
{ port: 443, targetPort: '443', protocol: 'TCP' },
|
||||
],
|
||||
selector: { app: 'web', tier: 'frontend' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('Service');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Type', value: 'NodePort' },
|
||||
{ label: 'Cluster IP', value: '10.96.12.4', wrap: true },
|
||||
{ label: 'External IPs', value: '203.0.113.7', wrap: true },
|
||||
{ label: 'Ports', value: 'http 80→8080/TCP (node 30080), 443/TCP', wrap: true },
|
||||
{ label: 'Selector', value: 'app=web, tier=frontend', wrap: true },
|
||||
{ label: 'Namespace', value: 'apps', wrap: true },
|
||||
{ label: 'Cluster', value: 'prod', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows an Ingress class, hosts and addresses', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'web-ingress',
|
||||
type: 'k8s-ingress',
|
||||
kubernetes: {
|
||||
className: 'nginx',
|
||||
hosts: ['shop.example.com', 'www.shop.example.com'],
|
||||
addresses: ['198.51.100.20'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Class', value: 'nginx' },
|
||||
{ label: 'Hosts', value: 'shop.example.com, www.shop.example.com', wrap: true },
|
||||
{ label: 'Addresses', value: '198.51.100.20', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags a claim that cannot bind and names its volume and size', () => {
|
||||
const pending = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'data-claim',
|
||||
type: 'k8s-persistent-volume-claim',
|
||||
kubernetes: {
|
||||
phase: 'Pending',
|
||||
storageClass: 'fast',
|
||||
requestedBytes: 10 * 1024 ** 3,
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(pending?.label).toBe('Volume claim');
|
||||
expect(pending?.rows).toEqual([
|
||||
{ label: 'Phase', value: 'Pending', tone: 'warning' },
|
||||
{ label: 'Storage class', value: 'fast' },
|
||||
{ label: 'Requested', value: '10 GB' },
|
||||
{ label: 'Access modes', value: 'ReadWriteOnce' },
|
||||
]);
|
||||
|
||||
const bound = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'data-claim',
|
||||
type: 'k8s-persistent-volume-claim',
|
||||
kubernetes: { phase: 'Bound', volumeName: 'pvc-0f3a', capacityBytes: 10 * 1024 ** 3 },
|
||||
}),
|
||||
);
|
||||
expect(bound?.rows).toEqual([
|
||||
{ label: 'Phase', value: 'Bound', tone: 'default' },
|
||||
{ label: 'Volume', value: 'pvc-0f3a', wrap: true },
|
||||
{ label: 'Capacity', value: '10 GB' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a persistent volume claim reference and reclaim policy', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'pvc-0f3a',
|
||||
type: 'k8s-persistent-volume',
|
||||
kubernetes: {
|
||||
phase: 'Failed',
|
||||
claimNamespace: 'apps',
|
||||
claimName: 'data-claim',
|
||||
capacityBytes: 10 * 1024 ** 3,
|
||||
storageClass: 'fast',
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
reclaimPolicy: 'Retain',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.label).toBe('Persistent volume');
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Phase', value: 'Failed', tone: 'danger' },
|
||||
{ label: 'Claim', value: 'apps/data-claim', wrap: true },
|
||||
{ label: 'Capacity', value: '10 GB' },
|
||||
{ label: 'Storage class', value: 'fast' },
|
||||
{ label: 'Access modes', value: 'ReadWriteOnce' },
|
||||
{ label: 'Reclaim policy', value: 'Retain' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('describes a storage class', () => {
|
||||
const section = buildKubernetesControllerSection(
|
||||
makeResource({
|
||||
id: 'fast',
|
||||
type: 'k8s-storage-class',
|
||||
kubernetes: {
|
||||
provisioner: 'rancher.io/local-path',
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'WaitForFirstConsumer',
|
||||
allowVolumeExpansion: false,
|
||||
parameterKeys: ['nodePath'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(section?.rows).toEqual([
|
||||
{ label: 'Provisioner', value: 'rancher.io/local-path', wrap: true },
|
||||
{ label: 'Reclaim policy', value: 'Delete' },
|
||||
{ label: 'Binding mode', value: 'WaitForFirstConsumer' },
|
||||
{ label: 'Volume expansion', value: 'Not allowed' },
|
||||
{ label: 'Parameters', value: 'nodePath', wrap: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kubernetes pod content and facts', () => {
|
||||
it("keeps a pod's containers as an Overview content card", () => {
|
||||
const containers = sectionByLabel(
|
||||
makeResource({
|
||||
id: 'pod-1',
|
||||
type: 'pod',
|
||||
kubernetes: { podContainers: [{ name: 'api', ready: true, state: 'running' }] },
|
||||
}),
|
||||
'Containers (1)',
|
||||
);
|
||||
expect(containers && isDrawerContentCard(containers)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag the finished containers of a completed pod', () => {
|
||||
const rows = sectionByLabel(
|
||||
makeResource({
|
||||
id: 'job-pod',
|
||||
type: 'pod',
|
||||
kubernetes: {
|
||||
podPhase: 'Succeeded',
|
||||
podContainers: [{ name: 'run', ready: false, state: 'terminated', reason: 'Completed' }],
|
||||
},
|
||||
}),
|
||||
'Containers (1)',
|
||||
)?.rows;
|
||||
expect(rows?.[0].tone).toBe('default');
|
||||
});
|
||||
|
||||
it('states namespace, owner and node for a pod only', () => {
|
||||
expect(
|
||||
getKubernetesPodFacts(
|
||||
makeResource({
|
||||
id: 'web-7d9-abc',
|
||||
type: 'pod',
|
||||
kubernetes: {
|
||||
namespace: 'apps',
|
||||
ownerKind: 'ReplicaSet',
|
||||
ownerName: 'web-7d9',
|
||||
nodeName: 'worker-2',
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual(['Namespace apps', 'ReplicaSet web-7d9', 'Node worker-2']);
|
||||
expect(
|
||||
getKubernetesPodFacts(
|
||||
makeResource({ id: 'web', type: 'k8s-deployment', kubernetes: { namespace: 'apps' } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { DetailSection } from '@/components/shared/detailSectionModel';
|
||||
import {
|
||||
buildResourceDrawerOverviewInventory,
|
||||
drawerContentCardTestId,
|
||||
isDrawerContentCard,
|
||||
} from '../resourceDetailDrawerOverviewModel';
|
||||
|
||||
const runtime: DetailSection = {
|
||||
label: 'Runtime context',
|
||||
rows: [
|
||||
{ label: 'Observed state', value: 'Online' },
|
||||
{ label: 'Uptime', value: '3d' },
|
||||
],
|
||||
};
|
||||
|
||||
const poolHealth: DetailSection = {
|
||||
label: 'Health',
|
||||
testId: drawerContentCardTestId('truenas-pool-health'),
|
||||
rows: [
|
||||
{ label: 'State', value: 'DEGRADED', tone: 'warning' },
|
||||
{ label: 'ZFS errors', value: 'Read 1 · Write 0 · Checksum 2', tone: 'warning' },
|
||||
{ label: 'Risk summary', value: 'ZFS pool tank is DEGRADED', tone: 'warning' },
|
||||
],
|
||||
};
|
||||
|
||||
const placement: DetailSection = {
|
||||
label: 'State',
|
||||
rows: [
|
||||
{ label: 'Accessible', value: 'No', tone: 'warning' },
|
||||
{ label: 'Datastore type', value: 'VMFS' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildResourceDrawerOverviewInventory', () => {
|
||||
it('lifts warning rows from platform sections as well as the summary', () => {
|
||||
const inventory = buildResourceDrawerOverviewInventory({
|
||||
summarySections: [runtime],
|
||||
platformSections: [placement],
|
||||
});
|
||||
expect(inventory.lifted).toEqual([{ label: 'Accessible', value: 'No', tone: 'warning' }]);
|
||||
// A lifted row moves rather than repeats; the rest stays under Details.
|
||||
expect(inventory.details).toEqual([
|
||||
runtime,
|
||||
{ label: 'State', rows: [{ label: 'Datastore type', value: 'VMFS' }] },
|
||||
]);
|
||||
expect(inventory.cards).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a content card whole and out of Details', () => {
|
||||
const inventory = buildResourceDrawerOverviewInventory({
|
||||
summarySections: [runtime],
|
||||
platformSections: [poolHealth],
|
||||
});
|
||||
expect(isDrawerContentCard(poolHealth)).toBe(true);
|
||||
expect(inventory.cards).toEqual([poolHealth]);
|
||||
expect(inventory.details).toEqual([runtime]);
|
||||
expect(inventory.lifted.map((row) => row.label)).toEqual([
|
||||
'State',
|
||||
'ZFS errors',
|
||||
'Risk summary',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not lift a sentence the alerts block already states', () => {
|
||||
const inventory = buildResourceDrawerOverviewInventory({
|
||||
summarySections: [],
|
||||
platformSections: [poolHealth],
|
||||
statedMessages: ['Pool tank needs attention. ZFS pool tank is DEGRADED.'],
|
||||
});
|
||||
expect(inventory.lifted.map((row) => row.label)).toEqual(['State', 'ZFS errors']);
|
||||
// The card still holds the stated row: it is the pool's full reading.
|
||||
expect(inventory.cards[0].rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('leaves a row an alert states by label in Details instead of lifting it', () => {
|
||||
const inventory = buildResourceDrawerOverviewInventory({
|
||||
summarySections: [
|
||||
{
|
||||
label: 'Runtime context',
|
||||
rows: [{ label: 'Observed state', value: 'Offline', tone: 'danger' }],
|
||||
},
|
||||
],
|
||||
platformSections: [],
|
||||
statedRowLabels: new Set(['Observed state']),
|
||||
});
|
||||
expect(inventory.lifted).toEqual([]);
|
||||
expect(inventory.details[0].rows).toEqual([
|
||||
{ label: 'Observed state', value: 'Offline', tone: 'danger' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a message-stated row in Details so the record stays complete', () => {
|
||||
const inventory = buildResourceDrawerOverviewInventory({
|
||||
summarySections: [],
|
||||
platformSections: [
|
||||
{
|
||||
label: 'Storage Health',
|
||||
rows: [{ label: 'Storage summary', value: 'Pool archive is degraded', tone: 'warning' }],
|
||||
},
|
||||
],
|
||||
statedMessages: ['Pool archive is degraded'],
|
||||
});
|
||||
expect(inventory.lifted).toEqual([]);
|
||||
expect(inventory.details[0].rows.map((row) => row.label)).toEqual(['Storage summary']);
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -225,16 +225,30 @@ describe('zfsDeviceEvidenceLabels filter and formatting (via "Affected vdevs")',
|
||||
// =========================================================================
|
||||
|
||||
describe('poolHealth canonical state tone', () => {
|
||||
it('tones success for canonicalState "ONLINE"', () => {
|
||||
it('omits the canonical state when it repeats the pool state', () => {
|
||||
const resource = storageRes({ poolHealth: { canonicalState: 'ONLINE' } });
|
||||
const row = findRow(resource, 'Canonical state');
|
||||
expect(findRow(resource, 'State')?.value).toBe('Online');
|
||||
expect(findRow(resource, 'Pool health')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tones success for a differing canonicalState "ONLINE"', () => {
|
||||
const resource = storageRes({
|
||||
zfsPoolState: 'RESILVERING',
|
||||
poolHealth: { canonicalState: 'ONLINE' },
|
||||
});
|
||||
const row = findRow(resource, 'Pool health');
|
||||
expect(row?.value).toBe('ONLINE');
|
||||
expect(row?.tone).toBe('success');
|
||||
});
|
||||
|
||||
it('tones danger for a faulted canonicalState', () => {
|
||||
const resource = storageRes({ poolHealth: { canonicalState: 'FAULTED' } });
|
||||
expect(findRow(resource, 'Pool health')?.tone).toBe('danger');
|
||||
});
|
||||
|
||||
it('tones default for a canonicalState outside the known sets', () => {
|
||||
const resource = storageRes({ poolHealth: { canonicalState: 'SUSPENDED' } });
|
||||
const row = findRow(resource, 'Canonical state');
|
||||
const row = findRow(resource, 'Pool health');
|
||||
expect(row?.value).toBe('SUSPENDED');
|
||||
expect(row?.tone).toBe('default');
|
||||
});
|
||||
|
||||
+3
-3
@@ -157,7 +157,7 @@ describe('truenasServiceStatus / serviceStatusLabel / serviceStatusTone', () =>
|
||||
expect(presentStatusRows).toEqual([testCase.status]);
|
||||
|
||||
const row = findRow(resource, testCase.status);
|
||||
expect(row?.value).toBe('1');
|
||||
expect(row?.value).toBe('1 service');
|
||||
expect(row?.tone).toBe(testCase.tone);
|
||||
});
|
||||
}
|
||||
@@ -185,8 +185,8 @@ describe('diskStateTone', () => {
|
||||
expect(findRow(diskResource({ health: 'OK' }), 'Health')?.tone).toBe('success');
|
||||
});
|
||||
|
||||
it('maps failed to warning', () => {
|
||||
expect(findRow(diskResource({ health: 'FAILED' }), 'Health')?.tone).toBe('warning');
|
||||
it('maps failed to danger', () => {
|
||||
expect(findRow(diskResource({ health: 'FAILED' }), 'Health')?.tone).toBe('danger');
|
||||
});
|
||||
|
||||
it('maps unrecognized health to default', () => {
|
||||
|
||||
+37
-27
@@ -165,17 +165,18 @@ describe('formatDurationSeconds (via System "Uptime" row)', () => {
|
||||
// summarizeList
|
||||
// =========================================================================
|
||||
|
||||
describe('summarizeList (via App list rows)', () => {
|
||||
// Copyable lists (addresses, images) are shown in full and wrap.
|
||||
describe('app list rows', () => {
|
||||
it('joins values within the default visible count', () => {
|
||||
expect(findRow(appRes({ usedHostIps: ['10.0.0.1', '10.0.0.2'] }), 'Host IPs')?.value).toBe(
|
||||
'10.0.0.1, 10.0.0.2',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends a +N suffix when values exceed the visible count', () => {
|
||||
expect(findRow(appRes({ usedHostIps: ['a', 'b', 'c', 'd', 'e'] }), 'Host IPs')?.value).toBe(
|
||||
'a, b, c +2',
|
||||
);
|
||||
it('lists every value rather than a +N suffix, wrapped', () => {
|
||||
const row = findRow(appRes({ usedHostIps: ['a', 'b', 'c', 'd', 'e'] }), 'Host IPs');
|
||||
expect(row?.value).toBe('a, b, c, d, e');
|
||||
expect(row?.wrap).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the row when the list is empty', () => {
|
||||
@@ -188,9 +189,9 @@ describe('summarizeList (via App list rows)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('respects a custom visible count (Images uses 2)', () => {
|
||||
it('lists every image', () => {
|
||||
expect(findRow(appRes({ images: ['img1', 'img2', 'img3', 'img4'] }), 'Images')?.value).toBe(
|
||||
'img1, img2 +2',
|
||||
'img1, img2, img3, img4',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -198,9 +199,9 @@ describe('summarizeList (via App list rows)', () => {
|
||||
expect(findRow(appRes({ images: ['img1', 'img2'] }), 'Images')?.value).toBe('img1, img2');
|
||||
});
|
||||
|
||||
it('populates the title attribute with the full list', () => {
|
||||
it('needs no separate title for the full list', () => {
|
||||
expect(findRow(appRes({ usedHostIps: ['a', 'b', 'c', 'd'] }), 'Host IPs')?.title).toBe(
|
||||
'a, b, c, d',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -593,8 +594,9 @@ describe('shareStateLabel (via Share "State" row value)', () => {
|
||||
// =========================================================================
|
||||
|
||||
describe('shareStateTone (via Share "State" row tone)', () => {
|
||||
it('returns warning when enabled === false', () => {
|
||||
expect(findRow(shareRes({ enabled: false }), 'State')?.tone).toBe('warning');
|
||||
// A disabled share is a choice, not something to check.
|
||||
it('returns default when enabled === false', () => {
|
||||
expect(findRow(shareRes({ enabled: false }), 'State')?.tone).toBe('default');
|
||||
});
|
||||
|
||||
it('returns warning when locked is true', () => {
|
||||
@@ -722,14 +724,20 @@ describe('buildTrueNASAppSections additional branches', () => {
|
||||
expect(findRow(appRes({ state: 'running' }), 'State')?.tone).toBe('success');
|
||||
});
|
||||
|
||||
it('sets State tone to warning when not running', () => {
|
||||
expect(findRow(appRes({ state: 'stopped' }), 'State')?.tone).toBe('warning');
|
||||
// A stopped app is a choice the row shows; a crashed one is a problem.
|
||||
it('keeps the default tone for a stopped app', () => {
|
||||
expect(findRow(appRes({ state: 'stopped' }), 'State')?.tone).toBe('default');
|
||||
});
|
||||
|
||||
it('shows "Available" with warning tone when upgrade is available', () => {
|
||||
it('sets State tone to danger for a crashed app', () => {
|
||||
expect(findRow(appRes({ state: 'CRASHED' }), 'State')?.tone).toBe('danger');
|
||||
});
|
||||
|
||||
it('shows "Available" as an actionable row when upgrade is available', () => {
|
||||
const row = findRow(appRes({ upgradeAvailable: true }), 'App updates');
|
||||
expect(row?.value).toBe('Available');
|
||||
expect(row?.tone).toBe('warning');
|
||||
expect(row?.tone).toBe('accent');
|
||||
expect(row?.lift).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Current" with success tone when no upgrade', () => {
|
||||
@@ -742,10 +750,11 @@ describe('buildTrueNASAppSections additional branches', () => {
|
||||
expect(findRow(appRes({ state: 'running' }), 'App updates')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows "Available" with warning tone when image updates are available', () => {
|
||||
it('shows "Available" as an actionable row when image updates are available', () => {
|
||||
const row = findRow(appRes({ imageUpdatesAvailable: true }), 'Image updates');
|
||||
expect(row?.value).toBe('Available');
|
||||
expect(row?.tone).toBe('warning');
|
||||
expect(row?.tone).toBe('accent');
|
||||
expect(row?.lift).toBe(true);
|
||||
});
|
||||
|
||||
it('shows "Current" with success tone when no image updates', () => {
|
||||
@@ -802,8 +811,9 @@ describe('buildTrueNASVMSections additional branches', () => {
|
||||
expect(findRow(resource, 'Domain state')?.value).toBe('Paused');
|
||||
});
|
||||
|
||||
it('sets State tone to warning for a non-running state', () => {
|
||||
expect(findRow(vmRes({ state: 'stopped', vcpus: 1 }), 'State')?.tone).toBe('warning');
|
||||
// Condition says when a stopped VM should be running; State alone is not a problem.
|
||||
it('keeps the default State tone for a non-running state', () => {
|
||||
expect(findRow(vmRes({ state: 'stopped', vcpus: 1 }), 'State')?.tone).toBe('default');
|
||||
});
|
||||
|
||||
it('uses domainState for State when state is absent', () => {
|
||||
@@ -937,7 +947,7 @@ describe('buildTrueNASDiskSections additional branches', () => {
|
||||
expect(row?.tone).toBe('default');
|
||||
});
|
||||
|
||||
it('shows Risk reasons with summarizeList +N suffix', () => {
|
||||
it('lists every risk reason', () => {
|
||||
const resource = diskRes({
|
||||
risk: {
|
||||
level: 'warning',
|
||||
@@ -948,7 +958,7 @@ describe('buildTrueNASDiskSections additional branches', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two +1');
|
||||
expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two, reason three');
|
||||
expect(findRow(resource, 'Risk')?.value).toBe('Warning');
|
||||
expect(findRow(resource, 'Risk')?.tone).toBe('warning');
|
||||
});
|
||||
@@ -988,19 +998,19 @@ describe('buildTrueNASStorageSections additional branches', () => {
|
||||
expect(findRow(resource, 'Risk')?.tone).toBe('default');
|
||||
});
|
||||
|
||||
it('shows Protection reduced (booleanValue) with warning tone when true', () => {
|
||||
it('shows Protection reduced as Yes with warning tone when true', () => {
|
||||
const row = findRow(storageRes({ protectionReduced: true }), 'Protection reduced');
|
||||
expect(row?.value).toBe('Enabled');
|
||||
expect(row?.value).toBe('Yes');
|
||||
expect(row?.tone).toBe('warning');
|
||||
});
|
||||
|
||||
it('shows Protection reduced (booleanValue) with success tone when false', () => {
|
||||
it('shows Protection reduced as No with success tone when false', () => {
|
||||
const row = findRow(storageRes({ protectionReduced: false }), 'Protection reduced');
|
||||
expect(row?.value).toBe('Disabled');
|
||||
expect(row?.value).toBe('No');
|
||||
expect(row?.tone).toBe('success');
|
||||
});
|
||||
|
||||
it('shows risk reasons with +N suffix', () => {
|
||||
it('lists every storage risk reason', () => {
|
||||
const resource = storageRes({
|
||||
risk: {
|
||||
level: 'warning',
|
||||
@@ -1011,7 +1021,7 @@ describe('buildTrueNASStorageSections additional branches', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two +1');
|
||||
expect(findRow(resource, 'Reasons')?.value).toBe('reason one, reason two, reason three');
|
||||
});
|
||||
|
||||
it('shows posture summary with warning tone when present', () => {
|
||||
|
||||
+80
-12
@@ -3,6 +3,7 @@ import {
|
||||
buildTrueNASDetailSections,
|
||||
buildTrueNASDetailsSummary,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel';
|
||||
import { isDrawerContentCard } from '@/components/Infrastructure/resourceDetailDrawerOverviewModel';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const baseResource = (overrides: Partial<Resource>): Resource =>
|
||||
@@ -51,8 +52,8 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
]);
|
||||
expect(buildTrueNASDetailSections(resource)[2]?.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Running', value: '2' }),
|
||||
expect.objectContaining({ label: 'Disabled', value: '1' }),
|
||||
expect.objectContaining({ label: 'Running', value: '2 services' }),
|
||||
expect.objectContaining({ label: 'Disabled', value: '1 service' }),
|
||||
expect.objectContaining({ label: 'Names', value: 'SMB, NFS, SSH, SMART' }),
|
||||
]),
|
||||
);
|
||||
@@ -299,13 +300,12 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const healthRows = buildTrueNASDetailSections(resource).find(
|
||||
(section) => section.label === 'Health',
|
||||
)?.rows;
|
||||
const sections = buildTrueNASDetailSections(resource);
|
||||
const healthRows = sections.find((section) => section.label === 'Health')?.rows;
|
||||
// The pool's health is an Overview card: what is wrong and what to do.
|
||||
expect(healthRows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Canonical state', value: 'DEGRADED' }),
|
||||
expect.objectContaining({ label: 'Native state', value: 'DEGRADED' }),
|
||||
expect.objectContaining({ label: 'State', value: 'DEGRADED', tone: 'warning' }),
|
||||
expect.objectContaining({
|
||||
label: 'Scan / resilver',
|
||||
value: 'Resilver Scanning (33.3%)',
|
||||
@@ -322,13 +322,16 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
label: 'Recommended',
|
||||
value: 'Confirm the affected mirror member in TrueNAS before replacement.',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'Evidence',
|
||||
value: 'zfs_pool_state, zfs_device_missing',
|
||||
}),
|
||||
expect.objectContaining({ label: 'Evidence source', value: 'pool.query' }),
|
||||
]),
|
||||
);
|
||||
// The canonical state repeats the pool state, so it is not shown twice.
|
||||
expect(healthRows?.find((row) => row.label === 'Pool health')).toBeUndefined();
|
||||
// Evidence codes and native state stay under Details.
|
||||
expect(sections.find((section) => section.label === 'Health evidence')?.rows).toEqual([
|
||||
expect.objectContaining({ label: 'Native state', value: 'DEGRADED' }),
|
||||
expect.objectContaining({ label: 'Evidence', value: 'zfs_pool_state, zfs_device_missing' }),
|
||||
expect.objectContaining({ label: 'Evidence source', value: 'pool.query' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes native TrueNAS physical disk metadata for the detail drawer', () => {
|
||||
@@ -429,4 +432,69 @@ describe('resourceDetailDrawerTrueNASModel', () => {
|
||||
|
||||
expect(buildTrueNASDetailsSummary(withoutLayout)).toContain('Pool');
|
||||
});
|
||||
|
||||
it('keeps pool health and disk health and SMART as Overview content cards', () => {
|
||||
const cards = (resource: Resource) =>
|
||||
buildTrueNASDetailSections(resource)
|
||||
.filter(isDrawerContentCard)
|
||||
.map((section) => section.label);
|
||||
const pool = baseResource({
|
||||
type: 'storage',
|
||||
platformScopes: ['truenas'],
|
||||
storage: { type: 'zfs-pool', topology: 'pool', platform: 'truenas', zfsPoolState: 'ONLINE' },
|
||||
});
|
||||
expect(cards(pool)).toEqual(['Health']);
|
||||
// A dataset has no pool health of its own: its state stays in Storage.
|
||||
const dataset = baseResource({
|
||||
type: 'storage',
|
||||
platformScopes: ['truenas'],
|
||||
storage: { type: 'zfs-dataset', topology: 'dataset', platform: 'truenas' },
|
||||
});
|
||||
expect(cards(dataset)).toEqual([]);
|
||||
expect(
|
||||
buildTrueNASDetailSections(dataset)
|
||||
.find((section) => section.label === 'Storage')
|
||||
?.rows.map((row) => row.label),
|
||||
).toContain('State');
|
||||
const disk = baseResource({
|
||||
type: 'physical_disk',
|
||||
platformScopes: ['truenas'],
|
||||
physicalDisk: { devPath: '/dev/sdc', health: 'PASSED', smart: { powerOnHours: 10 } },
|
||||
});
|
||||
expect(cards(disk)).toEqual(['Health', 'SMART']);
|
||||
});
|
||||
|
||||
it('states one storage summary once when several fields repeat it', () => {
|
||||
const resource = baseResource({
|
||||
type: 'agent',
|
||||
truenas: {
|
||||
storageRisk: { level: 'warning' },
|
||||
storageRiskSummary: 'ZFS pool archive is DEGRADED',
|
||||
storagePostureSummary: 'ZFS pool archive is DEGRADED',
|
||||
protectionSummary: 'ZFS pool archive is DEGRADED',
|
||||
},
|
||||
});
|
||||
const health = buildTrueNASDetailSections(resource).find(
|
||||
(section) => section.label === 'Storage Health',
|
||||
);
|
||||
expect(
|
||||
health?.rows
|
||||
.filter((row) => row.value === 'ZFS pool archive is DEGRADED')
|
||||
.map((row) => row.label),
|
||||
).toEqual(['Storage summary']);
|
||||
});
|
||||
|
||||
it('wraps disk identifiers an operator copies', () => {
|
||||
const disk = baseResource({
|
||||
type: 'physical_disk',
|
||||
platformScopes: ['truenas'],
|
||||
physicalDisk: { devPath: '/dev/sdc', serial: 'WD-WX12A3456', wwn: '0x5000c500a1b2c3d4' },
|
||||
});
|
||||
const identity = buildTrueNASDetailSections(disk).find((section) => section.label === 'Disk');
|
||||
expect(identity?.rows.filter((row) => row.wrap).map((row) => row.label)).toEqual([
|
||||
'Device',
|
||||
'Serial',
|
||||
'WWN',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -135,6 +135,7 @@ describe('flattenSnapshotRows (via Snapshot tree section)', () => {
|
||||
label: 'snap-9',
|
||||
value: 'snap-9',
|
||||
tone: 'default',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,8 +175,8 @@ describe('flattenSnapshotRows (via Snapshot tree section)', () => {
|
||||
},
|
||||
]);
|
||||
expect(rows).toEqual([
|
||||
{ label: 'root', value: 'current · poweredOn', tone: 'accent' },
|
||||
{ label: '- child', value: 'poweredOn', tone: 'default' },
|
||||
{ label: 'root', value: 'current · poweredOn', tone: 'accent', wrap: true },
|
||||
{ label: '- child', value: 'poweredOn', tone: 'default', wrap: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+15
-5
@@ -468,16 +468,25 @@ describe('getStatusTone (via Overall status row)', () => {
|
||||
'Overall status',
|
||||
);
|
||||
|
||||
it('maps red to warning', () => {
|
||||
expect(statusRow('red')).toEqual({ label: 'Overall status', value: 'red', tone: 'warning' });
|
||||
it('maps red to a danger-toned problem', () => {
|
||||
expect(statusRow('red')).toEqual({
|
||||
label: 'Overall status',
|
||||
value: 'Problem (red)',
|
||||
tone: 'danger',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps any other non-empty status to accent', () => {
|
||||
it('maps yellow to warning and green to success', () => {
|
||||
expect(statusRow('yellow')).toEqual({
|
||||
label: 'Overall status',
|
||||
value: 'yellow',
|
||||
tone: 'accent',
|
||||
value: 'Warning (yellow)',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(statusRow('green')?.tone).toBe('success');
|
||||
});
|
||||
|
||||
it('keeps an unrecognised status as reported, untoned', () => {
|
||||
expect(statusRow('purple')).toEqual({ label: 'Overall status', value: 'purple' });
|
||||
});
|
||||
|
||||
it('drops the row for an empty status (default tone branch)', () => {
|
||||
@@ -605,6 +614,7 @@ describe('flattenSnapshotRows (via Snapshot tree section)', () => {
|
||||
label: 'now',
|
||||
value: 'current · poweredOn',
|
||||
tone: 'accent',
|
||||
wrap: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+65
-8
@@ -24,13 +24,13 @@ describe('resourceDetailDrawerVmwareModel', () => {
|
||||
const placement = findSection(buildVMwareDetailSections('vm', vmware), 'Placement');
|
||||
|
||||
expect(placement?.rows).toEqual([
|
||||
{ label: 'Datacenter', value: 'Lab DC' },
|
||||
{ label: 'Cluster', value: 'Compute Cluster' },
|
||||
{ label: 'Datacenter', value: 'Lab DC', wrap: true },
|
||||
{ label: 'Cluster', value: 'Compute Cluster', wrap: true },
|
||||
{ label: 'Cluster services', value: 'HA enabled · DRS disabled' },
|
||||
{ label: 'Compute resource', value: 'Compute Cluster' },
|
||||
{ label: 'Resource pool', value: 'Production' },
|
||||
{ label: 'Runtime host', value: 'esxi-01.lab.local' },
|
||||
{ label: 'Datastores', value: 'shared-vsan' },
|
||||
{ label: 'Compute resource', value: 'Compute Cluster', wrap: true },
|
||||
{ label: 'Resource pool', value: 'Production', wrap: true },
|
||||
{ label: 'Runtime host', value: 'esxi-01.lab.local', wrap: true },
|
||||
{ label: 'Datastores', value: 'shared-vsan', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@ describe('resourceDetailDrawerVmwareModel', () => {
|
||||
value: 'Distributed Portgroup',
|
||||
});
|
||||
expect(findSection(sections, 'Network')?.rows).toEqual([
|
||||
{ label: 'Hosts', value: 'esxi-01.lab.local, esxi-02.lab.local' },
|
||||
{ label: 'VMs', value: 'warehouse-api-01, etl-batch-01' },
|
||||
{ label: 'Hosts', value: 'esxi-01.lab.local, esxi-02.lab.local', wrap: true },
|
||||
{ label: 'VMs', value: 'warehouse-api-01, etl-batch-01', wrap: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('resourceDetailDrawerVmwareModel', () => {
|
||||
label: 'Hard disk 1',
|
||||
value: 'SCSI 0:1 · 100 GB · nvme-primary · VMDK_FILE · [nvme-primary] app-01/app-01.vmdk',
|
||||
tone: 'default',
|
||||
wrap: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -206,6 +207,7 @@ describe('resourceDetailDrawerVmwareModel', () => {
|
||||
value:
|
||||
'VMXNET3 · VM Network · 00:50:56:aa:bb:cc · CONNECTED · starts connected · guest control',
|
||||
tone: 'default',
|
||||
wrap: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -216,12 +218,67 @@ describe('resourceDetailDrawerVmwareModel', () => {
|
||||
label: 'pre-upgrade',
|
||||
value: 'poweredOn · 2026-03-28 18:15 UTC · quiesced',
|
||||
tone: 'default',
|
||||
wrap: true,
|
||||
},
|
||||
{
|
||||
label: '- post-migration-checkpoint',
|
||||
value: 'current · poweredOn · 2026-03-29 18:15 UTC · not quiesced',
|
||||
tone: 'accent',
|
||||
wrap: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('lifts real vCenter problems and leaves settled states alone', () => {
|
||||
const tone = (sections: ReturnType<typeof buildVMwareDetailSections>, label: string) =>
|
||||
sections.flatMap((section) => section.rows).find((row) => row.label === label);
|
||||
|
||||
const datastore = buildVMwareDetailSections('storage', {
|
||||
datastoreAccessible: false,
|
||||
maintenanceMode: 'enteringMaintenance',
|
||||
overallStatus: 'yellow',
|
||||
});
|
||||
expect(tone(datastore, 'Accessible')).toMatchObject({ value: 'No', tone: 'warning' });
|
||||
expect(tone(datastore, 'Maintenance')).toMatchObject({
|
||||
value: 'Entering maintenance',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(tone(datastore, 'Overall status')).toMatchObject({
|
||||
value: 'Warning (yellow)',
|
||||
tone: 'warning',
|
||||
});
|
||||
|
||||
// vCenter reports "normal" for a datastore outside maintenance.
|
||||
const settled = buildVMwareDetailSections('storage', {
|
||||
maintenanceMode: 'normal',
|
||||
overallStatus: 'green',
|
||||
});
|
||||
expect(tone(settled, 'Maintenance')).toEqual({ label: 'Maintenance', value: 'No' });
|
||||
expect(tone(settled, 'Overall status')?.tone).toBe('success');
|
||||
|
||||
const host = buildVMwareDetailSections('agent', { connectionState: 'NOT_RESPONDING' });
|
||||
expect(tone(host, 'Connection state')).toMatchObject({
|
||||
value: 'Not Responding',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not flag Tools or vNICs of a powered-off VM', () => {
|
||||
const sections = buildVMwareDetailSections('vm', {
|
||||
powerState: 'POWERED_OFF',
|
||||
tools: { runState: 'NOT_RUNNING' },
|
||||
networkAdapters: [{ label: 'Network adapter 1', state: 'NOT_CONNECTED' }],
|
||||
});
|
||||
const rows = sections.flatMap((section) => section.rows);
|
||||
expect(rows.find((row) => row.label === 'Run state')?.tone).toBe('default');
|
||||
expect(rows.find((row) => row.label === 'Network adapter 1')?.tone).toBe('default');
|
||||
|
||||
const running = buildVMwareDetailSections('vm', {
|
||||
powerState: 'POWERED_ON',
|
||||
tools: { runState: 'NOT_RUNNING' },
|
||||
});
|
||||
expect(
|
||||
running.flatMap((section) => section.rows).find((row) => row.label === 'Run state')?.tone,
|
||||
).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
+230
-16
@@ -15,6 +15,7 @@ import {
|
||||
formatPlatformTableDurationValue,
|
||||
formatPlatformTableRelativeTimeValue,
|
||||
} from '@/features/platformPage/sharedPlatformPage';
|
||||
import { drawerContentCardTestId } from './resourceDetailDrawerOverviewModel';
|
||||
|
||||
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
||||
|
||||
@@ -36,7 +37,38 @@ const formatNodeBudget = (cores?: number, memoryBytes?: number, pods?: number):
|
||||
return parts.length > 0 ? parts.join(' / ') : null;
|
||||
};
|
||||
|
||||
const containerRow = (container: ResourceKubernetesPodContainerStatus, index: number) => {
|
||||
// The drawer is where a phone reaches values the table truncates or hides, so
|
||||
// identifiers an operator copies (images, addresses, names) wrap in full.
|
||||
const wrapRow = (label: string, value?: string | null) => makeRow(label, value, { wrap: true });
|
||||
|
||||
const joinValues = (values?: readonly (string | null | undefined)[]): string | null => {
|
||||
const visible = (values ?? []).map((value) => asString(value)).filter(Boolean);
|
||||
return visible.length > 0 ? visible.join(', ') : null;
|
||||
};
|
||||
|
||||
const formatKeyValues = (values?: Record<string, string>): string | null =>
|
||||
joinValues(
|
||||
Object.entries(values ?? {}).map(([key, value]) => (asString(value) ? `${key}=${value}` : key)),
|
||||
);
|
||||
|
||||
// A container of a finished pod (a Job's pod that ran to completion) is
|
||||
// terminated and not ready by design: that is not something to check.
|
||||
const containerNeedsAttention = (
|
||||
container: ResourceKubernetesPodContainerStatus,
|
||||
podPhase?: string,
|
||||
): boolean => {
|
||||
if (container.ready === true) return false;
|
||||
if (asString(podPhase)?.toLowerCase() === 'succeeded') return false;
|
||||
const state = asString(container.state)?.toLowerCase();
|
||||
const reason = asString(container.reason)?.toLowerCase();
|
||||
return !(state === 'terminated' && reason === 'completed');
|
||||
};
|
||||
|
||||
const containerRow = (
|
||||
container: ResourceKubernetesPodContainerStatus,
|
||||
index: number,
|
||||
podPhase?: string,
|
||||
) => {
|
||||
const state = asString(container.state) ?? 'unknown';
|
||||
const reason = asString(container.reason);
|
||||
const stateLabel =
|
||||
@@ -51,7 +83,8 @@ const containerRow = (container: ResourceKubernetesPodContainerStatus, index: nu
|
||||
const message = asString(container.message);
|
||||
return makeRow(asString(container.name) ?? `container ${index + 1}`, value, {
|
||||
title: message ? `${value}: ${message}` : value,
|
||||
tone: container.ready === true ? 'default' : 'warning',
|
||||
tone: containerNeedsAttention(container, podPhase) ? 'warning' : 'default',
|
||||
wrap: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -68,6 +101,8 @@ const isKubernetesNodeResource = (resource: Resource): boolean => {
|
||||
// vs allocatable budget for nodes, and the API server endpoint for clusters.
|
||||
// For nodes without a linked Pulse host agent this is the only place the OS
|
||||
// identity surfaces at all; the generic host section reads agent data only.
|
||||
// A pod's containers are its content, so they stay in view on the Overview
|
||||
// as a card, the way a guest's filesystems do.
|
||||
export const buildKubernetesDetailSections = (
|
||||
resource: Resource,
|
||||
): ResourceDetailDrawerKubernetesSection[] => {
|
||||
@@ -85,7 +120,10 @@ export const buildKubernetesDetailSections = (
|
||||
if (containers.length > 0) {
|
||||
sections.push({
|
||||
label: `Containers (${containers.length})`,
|
||||
rows: compactRows(containers.map((container, index) => containerRow(container, index))),
|
||||
testId: drawerContentCardTestId('kubernetes-containers'),
|
||||
rows: compactRows(
|
||||
containers.map((container, index) => containerRow(container, index, k.podPhase)),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -94,8 +132,8 @@ export const buildKubernetesDetailSections = (
|
||||
sections.push({
|
||||
label: 'Kubernetes node',
|
||||
rows: compactRows([
|
||||
makeRow('OS image', k.osImage),
|
||||
makeRow('Kernel', k.kernelVersion),
|
||||
wrapRow('OS image', k.osImage),
|
||||
wrapRow('Kernel', k.kernelVersion),
|
||||
makeRow('Architecture', k.architecture),
|
||||
makeRow(
|
||||
'Capacity',
|
||||
@@ -116,8 +154,8 @@ export const buildKubernetesDetailSections = (
|
||||
sections.push({
|
||||
label: 'Cluster',
|
||||
rows: compactRows([
|
||||
makeRow('API server', k.server),
|
||||
makeRow('Context', k.context),
|
||||
wrapRow('API server', k.server),
|
||||
wrapRow('Context', k.context),
|
||||
makeRow('Agent version', k.agentVersion),
|
||||
makeRow('Pending uninstall', k.pendingUninstall === true ? 'Yes' : null, {
|
||||
tone: 'warning',
|
||||
@@ -142,6 +180,70 @@ const countRow = (
|
||||
};
|
||||
|
||||
const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default');
|
||||
|
||||
// Kubernetes omits zero counts, so once a target is set a missing count means
|
||||
// none. A count short of the target reads "2 of 3" and is lifted to Worth
|
||||
// checking; one that meets it reads as a plain number.
|
||||
const shortfallRow = (
|
||||
label: string,
|
||||
value: number | undefined,
|
||||
desired: number | undefined,
|
||||
): DetailRow | null => {
|
||||
const target = typeof desired === 'number' && Number.isFinite(desired) ? desired : 0;
|
||||
const count = typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
if (target <= 0) return countRow(label, count);
|
||||
const actual = count ?? 0;
|
||||
if (actual >= target) return countRow(label, actual);
|
||||
return makeRow(
|
||||
label,
|
||||
`${formatDetailIntegerValue(actual)} of ${formatDetailIntegerValue(target)}`,
|
||||
{ tone: 'warning' },
|
||||
);
|
||||
};
|
||||
|
||||
const servicePortLabel = (
|
||||
port: NonNullable<NonNullable<Resource['kubernetes']>['servicePorts']>[number],
|
||||
): string | null => {
|
||||
if (typeof port.port !== 'number' || !Number.isFinite(port.port)) return null;
|
||||
const protocol = asString(port.protocol);
|
||||
const target = asString(port.targetPort);
|
||||
const name = asString(port.name);
|
||||
const nodePort =
|
||||
typeof port.nodePort === 'number' && port.nodePort > 0 ? ` (node ${port.nodePort})` : '';
|
||||
const mapping =
|
||||
target && target !== String(port.port) ? `${port.port}→${target}` : `${port.port}`;
|
||||
return `${name ? `${name} ` : ''}${mapping}${protocol ? `/${protocol}` : ''}${nodePort}`;
|
||||
};
|
||||
|
||||
// A claim that cannot bind, or a volume that failed, needs a look; Bound and
|
||||
// Available are the settled states.
|
||||
const volumePhaseTone = (phase?: string): DetailValueTone => {
|
||||
const normalized = asString(phase)?.toLowerCase();
|
||||
if (normalized === 'lost' || normalized === 'failed') return 'danger';
|
||||
if (normalized === 'pending') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const volumeSizeValue = (bytes?: number): string | null =>
|
||||
formatDetailBytesValue(bytes, { precision: 'compact', trimWhole: true });
|
||||
|
||||
const autoscalerRangeValue = (min?: number, max?: number): string | null => {
|
||||
const hasMax = typeof max === 'number' && max > 0;
|
||||
if (!hasMax) return null;
|
||||
// Kubernetes defaults minReplicas to 1 and omits it from the status.
|
||||
const floor = typeof min === 'number' && min > 0 ? min : 1;
|
||||
return `${formatDetailIntegerValue(floor)} to ${formatDetailCountValue(max, 'pod')}`;
|
||||
};
|
||||
|
||||
// An autoscaler held at its ceiling cannot add pods however busy the target
|
||||
// gets, which is worth a look.
|
||||
const autoscalerAtLimitRow = (current?: number, max?: number): DetailRow | null => {
|
||||
if (typeof max !== 'number' || max <= 0) return null;
|
||||
if (typeof current !== 'number' || current < max) return null;
|
||||
return makeRow('Scaling', `At its maximum of ${formatDetailCountValue(max, 'pod')}`, {
|
||||
tone: 'warning',
|
||||
});
|
||||
};
|
||||
const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default');
|
||||
|
||||
// Kubernetes reports controller timestamps as RFC 3339 strings. The table row
|
||||
@@ -183,6 +285,103 @@ const controllerRows = (resource: Resource, now: number | undefined): Controller
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return null;
|
||||
switch (resource.type) {
|
||||
case 'k8s-deployment':
|
||||
return {
|
||||
label: 'Deployment',
|
||||
rows: [
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
shortfallRow('Updated', k.updatedReplicas, k.desiredReplicas),
|
||||
shortfallRow('Ready', k.readyReplicas, k.desiredReplicas),
|
||||
shortfallRow('Available', k.availableReplicas, k.desiredReplicas),
|
||||
countRow('Observed generation', k.observedGeneration),
|
||||
],
|
||||
};
|
||||
case 'k8s-horizontal-pod-autoscaler':
|
||||
return {
|
||||
label: 'Autoscaler',
|
||||
rows: [
|
||||
wrapRow(
|
||||
'Target',
|
||||
[asString(k.targetKind), asString(k.targetName)].filter(Boolean).join(' '),
|
||||
),
|
||||
makeRow('Range', autoscalerRangeValue(k.minReplicas, k.maxReplicas)),
|
||||
countRow('Current', k.currentReplicas ?? (k.maxReplicas ? 0 : undefined), {
|
||||
noun: 'pod',
|
||||
}),
|
||||
countRow('Desired', k.desiredReplicas ?? (k.maxReplicas ? 0 : undefined), {
|
||||
noun: 'pod',
|
||||
}),
|
||||
autoscalerAtLimitRow(k.currentReplicas, k.maxReplicas),
|
||||
wrapRow('Metrics', joinValues(k.metricTypes)),
|
||||
],
|
||||
};
|
||||
case 'k8s-service':
|
||||
return {
|
||||
label: 'Service',
|
||||
rows: [
|
||||
makeRow('Type', k.serviceType),
|
||||
wrapRow('Cluster IP', k.clusterIp),
|
||||
wrapRow('External IPs', joinValues(k.externalIps)),
|
||||
wrapRow('Ports', joinValues((k.servicePorts ?? []).map(servicePortLabel))),
|
||||
wrapRow('Selector', formatKeyValues(k.selector)),
|
||||
],
|
||||
};
|
||||
case 'k8s-ingress':
|
||||
return {
|
||||
label: 'Ingress',
|
||||
rows: [
|
||||
makeRow('Class', k.className),
|
||||
wrapRow('Hosts', joinValues(k.hosts)),
|
||||
wrapRow('Addresses', joinValues(k.addresses)),
|
||||
],
|
||||
};
|
||||
case 'k8s-persistent-volume-claim':
|
||||
return {
|
||||
label: 'Volume claim',
|
||||
rows: [
|
||||
makeRow('Phase', k.phase, { tone: volumePhaseTone(k.phase) }),
|
||||
wrapRow('Volume', k.volumeName),
|
||||
makeRow('Storage class', k.storageClass),
|
||||
makeRow('Requested', volumeSizeValue(k.requestedBytes)),
|
||||
makeRow('Capacity', volumeSizeValue(k.capacityBytes)),
|
||||
makeRow('Access modes', joinValues(k.accessModes)),
|
||||
],
|
||||
};
|
||||
case 'k8s-persistent-volume':
|
||||
return {
|
||||
label: 'Persistent volume',
|
||||
rows: [
|
||||
makeRow('Phase', k.phase, { tone: volumePhaseTone(k.phase) }),
|
||||
wrapRow(
|
||||
'Claim',
|
||||
asString(k.claimName)
|
||||
? [asString(k.claimNamespace), asString(k.claimName)].filter(Boolean).join('/')
|
||||
: null,
|
||||
),
|
||||
makeRow('Capacity', volumeSizeValue(k.capacityBytes)),
|
||||
makeRow('Storage class', k.storageClass),
|
||||
makeRow('Access modes', joinValues(k.accessModes)),
|
||||
makeRow('Reclaim policy', k.reclaimPolicy),
|
||||
],
|
||||
};
|
||||
case 'k8s-storage-class':
|
||||
return {
|
||||
label: 'Storage class',
|
||||
rows: [
|
||||
wrapRow('Provisioner', k.provisioner),
|
||||
makeRow('Reclaim policy', k.reclaimPolicy),
|
||||
makeRow('Binding mode', k.volumeBindingMode),
|
||||
makeRow(
|
||||
'Volume expansion',
|
||||
k.allowVolumeExpansion === undefined
|
||||
? null
|
||||
: k.allowVolumeExpansion
|
||||
? 'Allowed'
|
||||
: 'Not allowed',
|
||||
),
|
||||
wrapRow('Parameters', joinValues(k.parameterKeys)),
|
||||
],
|
||||
};
|
||||
case 'k8s-replicaset':
|
||||
return {
|
||||
label: 'ReplicaSet',
|
||||
@@ -199,10 +398,10 @@ const controllerRows = (resource: Resource, now: number | undefined): Controller
|
||||
return {
|
||||
label: 'StatefulSet',
|
||||
rows: [
|
||||
makeRow('Service', k.serviceName),
|
||||
wrapRow('Service', k.serviceName),
|
||||
countRow('Target', k.desiredReplicas, { noun: 'pod' }),
|
||||
countRow('Current', k.currentReplicas),
|
||||
countRow('Ready', k.readyReplicas),
|
||||
shortfallRow('Ready', k.readyReplicas, k.desiredReplicas),
|
||||
countRow('Available', k.availableReplicas),
|
||||
countRow('Updated', k.updatedReplicas),
|
||||
],
|
||||
@@ -254,11 +453,12 @@ const controllerRows = (resource: Resource, now: number | undefined): Controller
|
||||
}
|
||||
};
|
||||
|
||||
// Workload-controller facts for the always-visible summary of the resource
|
||||
// drawer, the same slot Docker containers use for their timestamps. The
|
||||
// controllers table hides Scope and Detail below its large layout and Target
|
||||
// on a phone, so without this a Job's completion time or a CronJob's last
|
||||
// success is unreachable from a narrow row.
|
||||
// Workload-controller, network and storage facts for the resource drawer's
|
||||
// inventory, the same slot Docker containers use for their timestamps. The
|
||||
// Kubernetes tables hide Scope and Detail below their large layout and Target
|
||||
// on a phone, so without this a Job's completion time, a Service's address or
|
||||
// a claim's volume is unreachable from a narrow row. A shortfall (fewer ready
|
||||
// pods than the target) is warning-toned, so the Overview lifts it.
|
||||
export const buildKubernetesControllerSection = (
|
||||
resource: Resource,
|
||||
now?: number,
|
||||
@@ -267,8 +467,8 @@ export const buildKubernetesControllerSection = (
|
||||
if (!controller) return null;
|
||||
const rows = compactRows([
|
||||
...controller.rows,
|
||||
makeRow('Namespace', resource.kubernetes?.namespace),
|
||||
makeRow('Cluster', resource.kubernetes?.clusterName),
|
||||
wrapRow('Namespace', resource.kubernetes?.namespace),
|
||||
wrapRow('Cluster', resource.kubernetes?.clusterName),
|
||||
]);
|
||||
if (rows.length === 0) return null;
|
||||
return {
|
||||
@@ -303,3 +503,17 @@ export const buildKubernetesDetailsSummary = (resource: Resource): string | null
|
||||
|
||||
export const hasKubernetesDetailSections = (resource: Resource): boolean =>
|
||||
buildKubernetesDetailSections(resource).length > 0;
|
||||
|
||||
// The facts line for a pod: where it runs and what owns it. The pods table
|
||||
// hides Namespace, Owner and Node on a phone, so the drawer states them.
|
||||
export const getKubernetesPodFacts = (resource: Resource): string[] => {
|
||||
if (resource.type !== 'pod') return [];
|
||||
const k = resource.kubernetes;
|
||||
if (!k) return [];
|
||||
const ownerName = asString(k.ownerName);
|
||||
return [
|
||||
asString(k.namespace) ? `Namespace ${asString(k.namespace)}` : '',
|
||||
ownerName ? [asString(k.ownerKind), ownerName].filter(Boolean).join(' ') : '',
|
||||
asString(k.nodeName) ? `Node ${asString(k.nodeName)}` : '',
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
compactDetailSections,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import { partitionDrawerExceptionRows } from '@/components/Workloads/drawerOverviewModel';
|
||||
|
||||
// A platform section that is the object's own content (a pod's containers, a
|
||||
// pool's health, a disk's SMART readings) stays visible on the Overview, the
|
||||
// way filesystems and disks do in the Proxmox drawers. Every other section
|
||||
// waits under the closed Details disclosure.
|
||||
const CONTENT_CARD_TEST_ID_PREFIX = 'resource-drawer-card-';
|
||||
|
||||
export const drawerContentCardTestId = (name: string): string =>
|
||||
`${CONTENT_CARD_TEST_ID_PREFIX}${name}`;
|
||||
|
||||
export const isDrawerContentCard = (section: DetailSection): boolean =>
|
||||
section.testId?.startsWith(CONTENT_CARD_TEST_ID_PREFIX) === true;
|
||||
|
||||
const rowKey = (row: DetailRow): string => `${row.label}\u0000${row.value}`;
|
||||
|
||||
const sentenceKey = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[.\s]+$/, '');
|
||||
|
||||
// The alerts block above already states these sentences (an alert message, a
|
||||
// pool's own risk summary). A lifted row repeating one of them, whole or as one
|
||||
// sentence of a longer message, says nothing new.
|
||||
const isStatedBy = (row: DetailRow, statedMessages: readonly string[]): boolean => {
|
||||
const key = sentenceKey(row.value);
|
||||
if (!key) return false;
|
||||
const sentence = `. ${key}. `;
|
||||
return statedMessages.some((message) => `. ${sentenceKey(message)}. `.includes(sentence));
|
||||
};
|
||||
|
||||
export interface ResourceDrawerOverviewInventory {
|
||||
/** Warning, danger and actionable rows for "Worth checking". */
|
||||
lifted: DetailRow[];
|
||||
/** Content cards shown in full on the Overview. */
|
||||
cards: DetailSection[];
|
||||
/** Everything else, for the closed Details disclosure. */
|
||||
details: DetailSection[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a resource drawer's inventory (the shared summary sections and the
|
||||
* platform's own sections) the way the Proxmox drawers do: anything wrong is
|
||||
* lifted in front once, content cards stay visible in full, and the rest goes
|
||||
* under Details. A row an open alert already states (by label or by message)
|
||||
* is not lifted again.
|
||||
*/
|
||||
export const buildResourceDrawerOverviewInventory = (options: {
|
||||
summarySections: readonly DetailSection[];
|
||||
platformSections: readonly DetailSection[];
|
||||
statedRowLabels?: ReadonlySet<string>;
|
||||
statedMessages?: readonly string[];
|
||||
}): ResourceDrawerOverviewInventory => {
|
||||
const sections = [...options.summarySections, ...options.platformSections];
|
||||
const statedMessages = options.statedMessages ?? [];
|
||||
const lifted = partitionDrawerExceptionRows(sections, options.statedRowLabels).lifted.filter(
|
||||
(row) => !isStatedBy(row, statedMessages),
|
||||
);
|
||||
// Lifted rows move rather than repeat. A row left in front only because an
|
||||
// alert states it stays in Details, where the record is complete.
|
||||
const liftedKeys = new Set(lifted.map(rowKey));
|
||||
return {
|
||||
lifted,
|
||||
// A content card keeps every row: it is the object's whole reading, as a
|
||||
// full filesystem stays in the guest drawer's Filesystems card.
|
||||
cards: compactDetailSections(sections.filter(isDrawerContentCard)),
|
||||
details: compactDetailSections(
|
||||
sections
|
||||
.filter((section) => !isDrawerContentCard(section))
|
||||
.map((section) => ({
|
||||
...section,
|
||||
rows: section.rows.filter((row) => !liftedKeys.has(rowKey(row))),
|
||||
})),
|
||||
),
|
||||
};
|
||||
};
|
||||
+131
-66
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/features/storageBackups/diskTemperaturePresentation';
|
||||
import type { TrueNASDiskTemperatureThresholdResolver } from '@/features/truenas/truenasPageModel';
|
||||
import { hasImpairedResourceSource } from '@/utils/resourceSourceHealth';
|
||||
import { drawerContentCardTestId } from './resourceDetailDrawerOverviewModel';
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone;
|
||||
|
||||
@@ -92,6 +93,36 @@ const row = makeDetailRow;
|
||||
const compactRows = compactDetailRows;
|
||||
const compactSections = compactDetailSections;
|
||||
|
||||
// Identifiers and paths an operator copies (hostnames, serials, dataset
|
||||
// paths, addresses) wrap in full instead of ending in an ellipsis.
|
||||
const wrapRow = (
|
||||
label: string,
|
||||
value?: string | null,
|
||||
options: Parameters<typeof makeDetailRow>[2] = {},
|
||||
) => makeDetailRow(label, value, { ...options, wrap: true });
|
||||
|
||||
const joinList = (values: readonly string[]): string =>
|
||||
values
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
// The health rows often repeat one summary under several labels (a degraded
|
||||
// pool's risk, posture and protection all say so). The first label keeps it.
|
||||
const withoutRepeatedValues = (
|
||||
rows: Array<ResourceDetailDrawerTrueNASRow | null>,
|
||||
): ResourceDetailDrawerTrueNASRow[] => {
|
||||
const seen = new Set<string>();
|
||||
return compactRows(rows).filter((detailRow) => {
|
||||
// Short values ("Yes", "2 services") legitimately repeat; sentences do not.
|
||||
if (!/\s/.test(detailRow.value) || detailRow.value.length < 16) return true;
|
||||
const key = detailRow.value.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const isTrueNASScopedResource = (resource: Resource): boolean =>
|
||||
resource.platformType === 'truenas' ||
|
||||
resource.platformScopes?.includes('truenas') === true ||
|
||||
@@ -152,13 +183,13 @@ const buildTrueNASSystemSections = (
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const pids = services.flatMap((service) => service.pids ?? []).filter((pid) => pid > 0);
|
||||
const systemRows = compactRows([
|
||||
row('Hostname', asString(truenas.hostname) ?? asString(resource.name)),
|
||||
wrapRow('Hostname', asString(truenas.hostname) ?? asString(resource.name)),
|
||||
row('Version', asString(truenas.version)),
|
||||
row('Uptime', formatDurationSeconds(truenas.uptimeSeconds ?? resource.uptime)),
|
||||
row('Status', normalizeDelimitedLabel(resource.status)),
|
||||
]);
|
||||
|
||||
const healthRows = compactRows([
|
||||
const healthRows = withoutRepeatedValues([
|
||||
row('Storage risk', normalizeDelimitedLabel(truenas.storageRisk?.level), {
|
||||
tone: truenas.storageRisk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
@@ -186,7 +217,7 @@ const buildTrueNASSystemSections = (
|
||||
...(['running', 'attention', 'stopped', 'disabled'] as TrueNASServiceStatus[]).map((status) =>
|
||||
row(
|
||||
serviceStatusLabel(status),
|
||||
serviceCounts[status] > 0 ? `${serviceCounts[status]}` : null,
|
||||
serviceCounts[status] > 0 ? formatDetailCountValue(serviceCounts[status], 'service') : null,
|
||||
{
|
||||
tone: serviceStatusTone(status),
|
||||
},
|
||||
@@ -225,6 +256,7 @@ const storageStateTone = (
|
||||
const state = (storage.zfsPoolState ?? storage.arrayState ?? resource.status).toLowerCase();
|
||||
if (state === 'online' || state === 'healthy' || state === 'mounted') return 'success';
|
||||
if (state === 'degraded' || state === 'warning' || state === 'offline') return 'warning';
|
||||
if (['faulted', 'unavail', 'removed', 'suspended'].includes(state)) return 'danger';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
@@ -292,6 +324,20 @@ const zfsDeviceEvidenceLabels = (storage: ResourceStorageMeta): string[] =>
|
||||
return `${name}${context ? ` (${context})` : ''}: ${state}${errors}`;
|
||||
});
|
||||
|
||||
// A pool's health is its content, the way RAID is a Proxmox node's: it stays
|
||||
// in view on the Overview. A dataset or share has no pool health of its own.
|
||||
const isTrueNASPool = (storage: ResourceStorageMeta): boolean =>
|
||||
Boolean(storage.zfsPool || storage.poolHealth) ||
|
||||
asString(storage.topology)?.toLowerCase() === 'pool' ||
|
||||
asString(storage.type)?.toLowerCase().includes('pool') === true;
|
||||
|
||||
const canonicalPoolStateTone = (state?: string): ResourceDetailDrawerTrueNASRowTone => {
|
||||
if (state === 'ONLINE') return 'success';
|
||||
if (state === 'DEGRADED' || state === 'OFFLINE') return 'warning';
|
||||
if (state === 'FAULTED' || state === 'UNAVAIL') return 'danger';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const buildTrueNASStorageSections = (
|
||||
resource: Resource,
|
||||
storage: ResourceStorageMeta,
|
||||
@@ -300,13 +346,16 @@ const buildTrueNASStorageSections = (
|
||||
.map((reason) => asString(reason.summary))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const zfsDeviceEvidence = zfsDeviceEvidenceLabels(storage);
|
||||
const stateLabel = storageStateLabel(resource, storage);
|
||||
const stateRow = row('State', stateLabel, { tone: storageStateTone(resource, storage) });
|
||||
const canonicalState = asString(storage.poolHealth?.canonicalState);
|
||||
const pool = isTrueNASPool(storage);
|
||||
const storageRows = compactRows([
|
||||
row('Kind', storageKindLabel(storage)),
|
||||
row('State', storageStateLabel(resource, storage), {
|
||||
tone: storageStateTone(resource, storage),
|
||||
}),
|
||||
row('Pool', asString(storage.pool) ?? asString(resource.parentName)),
|
||||
row('Path', asString(storage.path)),
|
||||
// A pool states its state in its health card; a dataset here.
|
||||
pool ? null : stateRow,
|
||||
wrapRow('Pool', asString(storage.pool) ?? asString(resource.parentName)),
|
||||
wrapRow('Path', asString(storage.path)),
|
||||
row('Protection', storageProtectionLabel(storage.protection)),
|
||||
row('Shared', yesNoValue(storage.shared)),
|
||||
]);
|
||||
@@ -320,18 +369,17 @@ const buildTrueNASStorageSections = (
|
||||
row('Consumers', formatDetailIntegerValue(storage.consumerCount)),
|
||||
]);
|
||||
|
||||
const healthRows = compactRows([
|
||||
row('Canonical state', asString(storage.poolHealth?.canonicalState), {
|
||||
tone:
|
||||
storage.poolHealth?.canonicalState === 'ONLINE'
|
||||
? 'success'
|
||||
: ['DEGRADED', 'FAULTED', 'OFFLINE', 'UNAVAIL'].includes(
|
||||
storage.poolHealth?.canonicalState ?? '',
|
||||
)
|
||||
? 'warning'
|
||||
: 'default',
|
||||
}),
|
||||
row('Native state', asString(storage.poolHealth?.nativeState)),
|
||||
// What the operator reads first: is the pool OK, and if not, why and what
|
||||
// to do. Evidence codes and native states stay under Details.
|
||||
const healthRows = withoutRepeatedValues([
|
||||
pool ? stateRow : null,
|
||||
row(
|
||||
'Pool health',
|
||||
canonicalState && canonicalState.toLowerCase() !== stateLabel?.toLowerCase()
|
||||
? canonicalState
|
||||
: null,
|
||||
{ tone: canonicalPoolStateTone(canonicalState ?? undefined) },
|
||||
),
|
||||
row('Risk', normalizeDelimitedLabel(storage.risk?.level), {
|
||||
tone: storage.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
@@ -339,7 +387,7 @@ const buildTrueNASStorageSections = (
|
||||
row('Posture', asString(storage.postureSummary), {
|
||||
tone: storage.postureSummary ? 'warning' : 'default',
|
||||
}),
|
||||
row('Protection reduced', booleanValue(storage.protectionReduced), {
|
||||
row('Protection reduced', yesNoValue(storage.protectionReduced), {
|
||||
tone: storage.protectionReduced ? 'warning' : 'success',
|
||||
}),
|
||||
row('Protection summary', asString(storage.protectionSummary), {
|
||||
@@ -350,34 +398,36 @@ const buildTrueNASStorageSections = (
|
||||
tone: storage.zfsPool?.scanDetails?.errors ? 'warning' : 'default',
|
||||
}),
|
||||
row('ZFS errors', zfsErrorLabel(storage), { tone: 'warning' }),
|
||||
row('Affected vdevs', summarizeList(zfsDeviceEvidence, 2), {
|
||||
title: zfsDeviceEvidence.join(', '),
|
||||
tone: 'warning',
|
||||
}),
|
||||
row('Reasons', summarizeList(riskReasons, 2), {
|
||||
title: riskReasons.join(', '),
|
||||
tone: 'warning',
|
||||
}),
|
||||
row('Recommended', asString(storage.poolHealth?.recommendation), {
|
||||
wrapRow('Affected vdevs', joinList(zfsDeviceEvidence), { tone: 'warning' }),
|
||||
wrapRow('Reasons', joinList(riskReasons), { tone: 'warning' }),
|
||||
wrapRow('Recommended', asString(storage.poolHealth?.recommendation), {
|
||||
title: asString(storage.poolHealth?.summary) ?? undefined,
|
||||
}),
|
||||
row('Evidence', summarizeList(storage.poolHealth?.evidenceCodes ?? [], 4), {
|
||||
title: (storage.poolHealth?.evidenceCodes ?? []).join(', '),
|
||||
}),
|
||||
]);
|
||||
|
||||
const evidenceRows = compactRows([
|
||||
row('Native state', asString(storage.poolHealth?.nativeState)),
|
||||
wrapRow('Evidence', joinList(storage.poolHealth?.evidenceCodes ?? [])),
|
||||
row('Evidence source', asString(storage.poolHealth?.source)),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
{ label: 'Storage', rows: storageRows },
|
||||
{ label: 'Capacity', rows: capacityRows },
|
||||
{ label: 'Health', rows: healthRows },
|
||||
{
|
||||
label: 'Health',
|
||||
rows: healthRows,
|
||||
...(pool ? { testId: drawerContentCardTestId('truenas-pool-health') } : {}),
|
||||
},
|
||||
{ label: 'Health evidence', rows: evidenceRows },
|
||||
]);
|
||||
};
|
||||
|
||||
const diskStateTone = (disk: ResourcePhysicalDiskMeta): ResourceDetailDrawerTrueNASRowTone => {
|
||||
const health = disk.health?.toLowerCase();
|
||||
if (health === 'passed' || health === 'healthy' || health === 'ok') return 'success';
|
||||
if (health === 'degraded' || health === 'failed' || health === 'warning') return 'warning';
|
||||
if (health === 'failed') return 'danger';
|
||||
if (health === 'degraded' || health === 'warning') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
@@ -443,10 +493,10 @@ const buildTrueNASDiskSections = (
|
||||
.map((reason) => asString(reason.summary))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
const identityRows = compactRows([
|
||||
row('Device', asString(disk.devPath) ?? asString(resource.name)),
|
||||
row('Model', asString(disk.model)),
|
||||
row('Serial', asString(disk.serial)),
|
||||
row('WWN', asString(disk.wwn)),
|
||||
wrapRow('Device', asString(disk.devPath) ?? asString(resource.name)),
|
||||
wrapRow('Model', asString(disk.model)),
|
||||
wrapRow('Serial', asString(disk.serial)),
|
||||
wrapRow('WWN', asString(disk.wwn)),
|
||||
row('Type', diskTypeLabel(disk.diskType)),
|
||||
row('Size', formatDetailBytesValue(disk.sizeBytes)),
|
||||
]);
|
||||
@@ -460,7 +510,7 @@ const buildTrueNASDiskSections = (
|
||||
),
|
||||
row('RPM', formatDetailIntegerValue(disk.rpm)),
|
||||
row('Role', normalizeDelimitedLabel(disk.storageRole)),
|
||||
row('Group', asString(disk.storageGroup) ?? asString(resource.parentName)),
|
||||
wrapRow('Group', asString(disk.storageGroup) ?? asString(resource.parentName)),
|
||||
row('State', normalizeDelimitedLabel(disk.storageState)),
|
||||
row('Spun down', yesNoValue(disk.spunDown)),
|
||||
]);
|
||||
@@ -492,16 +542,15 @@ const buildTrueNASDiskSections = (
|
||||
row('Risk', normalizeDelimitedLabel(disk.risk?.level), {
|
||||
tone: disk.risk?.level?.toLowerCase() === 'warning' ? 'warning' : 'default',
|
||||
}),
|
||||
row('Reasons', summarizeList(riskReasons, 2), {
|
||||
title: riskReasons.join(', '),
|
||||
tone: 'warning',
|
||||
}),
|
||||
wrapRow('Reasons', joinList(riskReasons), { tone: 'warning' }),
|
||||
]);
|
||||
|
||||
// Health and SMART readings are the disk's content, the way the Proxmox node
|
||||
// drawer shows its disks: they stay in view on the Overview.
|
||||
return compactSections([
|
||||
{ label: 'Disk', rows: identityRows },
|
||||
{ label: 'Health', rows: healthRows },
|
||||
{ label: 'SMART', rows: smartRows },
|
||||
{ label: 'Health', rows: healthRows, testId: drawerContentCardTestId('truenas-disk-health') },
|
||||
{ label: 'SMART', rows: smartRows, testId: drawerContentCardTestId('truenas-disk-smart') },
|
||||
{ label: 'Risk', rows: riskRows },
|
||||
]);
|
||||
};
|
||||
@@ -552,18 +601,28 @@ const buildTrueNASAppSections = (
|
||||
const volumeLabels = appVolumeLabels(app);
|
||||
const imageLabels = (app.images ?? []).map((image) => image.trim()).filter(Boolean);
|
||||
const networkLabels = appNetworkLabels(app);
|
||||
const appState = asString(app.state)?.toLowerCase();
|
||||
const appRows = compactRows([
|
||||
// A stopped app is a choice the row already shows; a crashed one is not.
|
||||
row('State', normalizeDelimitedLabel(app.state), {
|
||||
tone: app.state?.toLowerCase() === 'running' ? 'success' : 'warning',
|
||||
tone:
|
||||
appState === 'running'
|
||||
? 'success'
|
||||
: appState === 'crashed' || appState === 'error' || appState === 'failed'
|
||||
? 'danger'
|
||||
: 'default',
|
||||
}),
|
||||
row('Version', asString(app.humanVersion) ?? asString(app.version)),
|
||||
row('Containers', formatDetailIntegerValue(containerCount)),
|
||||
row('Custom app', yesNoValue(app.customApp)),
|
||||
// An available update is something to act on, as for a Docker container:
|
||||
// the Overview lifts it in front.
|
||||
row(
|
||||
'App updates',
|
||||
app.upgradeAvailable === undefined ? null : app.upgradeAvailable ? 'Available' : 'Current',
|
||||
{
|
||||
tone: app.upgradeAvailable ? 'warning' : 'success',
|
||||
tone: app.upgradeAvailable ? 'accent' : 'success',
|
||||
lift: app.upgradeAvailable === true,
|
||||
},
|
||||
),
|
||||
row(
|
||||
@@ -573,22 +632,23 @@ const buildTrueNASAppSections = (
|
||||
: app.imageUpdatesAvailable
|
||||
? 'Available'
|
||||
: 'Current',
|
||||
{ tone: app.imageUpdatesAvailable ? 'warning' : 'success' },
|
||||
{
|
||||
tone: app.imageUpdatesAvailable ? 'accent' : 'success',
|
||||
lift: app.imageUpdatesAvailable === true,
|
||||
},
|
||||
),
|
||||
row('Notes', asString(app.notes)),
|
||||
]);
|
||||
|
||||
const networkRows = compactRows([
|
||||
row('Host IPs', summarizeList(app.usedHostIps ?? []), {
|
||||
title: (app.usedHostIps ?? []).join(', '),
|
||||
}),
|
||||
row('Ports', summarizeList(portLabels), { title: portLabels.join(', ') }),
|
||||
row('Networks', summarizeList(networkLabels), { title: networkLabels.join(', ') }),
|
||||
wrapRow('Host IPs', joinList(app.usedHostIps ?? [])),
|
||||
wrapRow('Ports', joinList(portLabels)),
|
||||
wrapRow('Networks', joinList(networkLabels)),
|
||||
]);
|
||||
|
||||
const storageRows = compactRows([
|
||||
row('Volumes', summarizeList(volumeLabels), { title: volumeLabels.join(', ') }),
|
||||
row('Images', summarizeList(imageLabels, 2), { title: imageLabels.join(', ') }),
|
||||
wrapRow('Volumes', joinList(volumeLabels)),
|
||||
wrapRow('Images', joinList(imageLabels)),
|
||||
]);
|
||||
|
||||
return compactSections([
|
||||
@@ -644,8 +704,10 @@ const buildTrueNASVMSections = (
|
||||
const sameState = state?.toLowerCase() === domainState?.toLowerCase();
|
||||
const machine = [asString(vm.machineType), asString(vm.archType)].filter(Boolean).join(' / ');
|
||||
const computeRows = compactRows([
|
||||
// A stopped VM is a choice the row already shows. Condition below says
|
||||
// when it should be running.
|
||||
row('State', state ?? domainState, {
|
||||
tone: (vm.state ?? vm.domainState)?.toLowerCase() === 'running' ? 'success' : 'warning',
|
||||
tone: (vm.state ?? vm.domainState)?.toLowerCase() === 'running' ? 'success' : 'default',
|
||||
}),
|
||||
row('Domain state', sameState ? null : domainState),
|
||||
row('Condition', vmConditionLabel(resource, vm), { tone: 'warning' }),
|
||||
@@ -654,14 +716,14 @@ const buildTrueNASVMSections = (
|
||||
row('Memory', formatDetailBytesValue(vm.memoryBytes)),
|
||||
row('Minimum memory', formatDetailBytesValue(vm.minMemoryBytes)),
|
||||
row('CPU mode', normalizeDelimitedLabel(vm.cpuMode)),
|
||||
row('CPU model', asString(vm.cpuModel)),
|
||||
wrapRow('CPU model', asString(vm.cpuModel)),
|
||||
]);
|
||||
|
||||
const runtimeRows = compactRows([
|
||||
row('Bootloader', asString(vm.bootloader)),
|
||||
row('Machine', machine),
|
||||
row('Process ID', formatDetailIntegerValue(vm.pid)),
|
||||
row('UUID', asString(vm.uuid)),
|
||||
wrapRow('UUID', asString(vm.uuid)),
|
||||
]);
|
||||
|
||||
const deviceRows = compactRows([
|
||||
@@ -699,8 +761,11 @@ const shareStateLabel = (share: ResourceTrueNASShareMeta): string => {
|
||||
return 'Enabled';
|
||||
};
|
||||
|
||||
// A disabled share is a choice; a locked one (its encrypted dataset is
|
||||
// locked) cannot serve clients until someone unlocks it.
|
||||
const shareStateTone = (share: ResourceTrueNASShareMeta): ResourceDetailDrawerTrueNASRowTone => {
|
||||
if (share.enabled === false || share.locked) return 'warning';
|
||||
if (share.enabled === false) return 'default';
|
||||
if (share.locked) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
@@ -727,9 +792,9 @@ const buildTrueNASShareSections = (
|
||||
const shareRows = compactRows([
|
||||
row('State', shareStateLabel(share), { tone: shareStateTone(share) }),
|
||||
row('Protocol', shareProtocolLabel(share)),
|
||||
row('Dataset', asString(share.dataset)),
|
||||
row('Path', asString(share.path)),
|
||||
row('Relative path', asString(share.relativePath)),
|
||||
wrapRow('Dataset', asString(share.dataset)),
|
||||
wrapRow('Path', asString(share.path)),
|
||||
wrapRow('Relative path', asString(share.relativePath)),
|
||||
row('Comment', asString(share.comment)),
|
||||
]);
|
||||
|
||||
@@ -743,9 +808,9 @@ const buildTrueNASShareSections = (
|
||||
]);
|
||||
|
||||
const clientRows = compactRows([
|
||||
row('Aliases', summarizeList(aliases), { title: aliases.join(', ') }),
|
||||
row('Hosts', summarizeList(hosts), { title: hosts.join(', ') }),
|
||||
row('Networks', summarizeList(networks), { title: networks.join(', ') }),
|
||||
wrapRow('Aliases', joinList(aliases)),
|
||||
wrapRow('Hosts', joinList(hosts)),
|
||||
wrapRow('Networks', joinList(networks)),
|
||||
row('Security', summarizeList(security), { title: security.join(', ') }),
|
||||
row('Map root', shareUserGroupLabel(share.mapRootUser, share.mapRootGroup)),
|
||||
row('Map all', shareUserGroupLabel(share.mapAllUser, share.mapAllGroup)),
|
||||
|
||||
@@ -21,6 +21,24 @@ import {
|
||||
|
||||
const asTrimmedString = (value?: string | null): string => (value || '').trim();
|
||||
|
||||
// Names, addresses and paths an operator copies into vCenter or a shell wrap
|
||||
// in full instead of ending in an ellipsis.
|
||||
const wrapRow = (
|
||||
label: string,
|
||||
value?: string | null,
|
||||
options: Parameters<typeof makeRow>[2] = {},
|
||||
) => makeRow(label, value, { ...options, wrap: true });
|
||||
|
||||
// Tools and vNIC state only mean something while the VM runs: a powered-off
|
||||
// VM has no running Tools and no connected adapters by design. Without a
|
||||
// reported power state the readings are taken at face value.
|
||||
const isPoweredOff = (vmware?: ResourceVMwareMeta): boolean => {
|
||||
const state = asTrimmedString(vmware?.powerState)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z]/g, '');
|
||||
return state === 'poweredoff' || state === 'suspended' || state === 'off';
|
||||
};
|
||||
|
||||
const summarizeList = (values: string[] | undefined): string =>
|
||||
(values ?? []).map(asTrimmedString).filter(Boolean).join(', ');
|
||||
|
||||
@@ -107,7 +125,7 @@ const flattenSnapshotRows = (
|
||||
): DetailRow[] => {
|
||||
const rows: DetailRow[] = [];
|
||||
for (const snapshot of snapshots ?? []) {
|
||||
const snapshotRow = makeRow(
|
||||
const snapshotRow = wrapRow(
|
||||
`${depth > 0 ? `${'-'.repeat(depth)} ` : ''}${snapshotDisplayName(snapshot)}`,
|
||||
snapshotValue(snapshot) || asTrimmedString(snapshot.snapshot),
|
||||
{ tone: snapshot.current ? 'accent' : 'default' },
|
||||
@@ -161,11 +179,14 @@ const adapterValue = (adapter: ResourceVMwareNetworkAdapter): string => {
|
||||
const adapterTone = (adapter: ResourceVMwareNetworkAdapter): DetailValueTone =>
|
||||
asTrimmedString(adapter.state).toLowerCase() === 'not_connected' ? 'warning' : 'default';
|
||||
|
||||
const networkAdapterRows = (adapters: ResourceVMwareNetworkAdapter[] | undefined): DetailRow[] =>
|
||||
const networkAdapterRows = (
|
||||
adapters: ResourceVMwareNetworkAdapter[] | undefined,
|
||||
poweredOff: boolean,
|
||||
): DetailRow[] =>
|
||||
compactRows(
|
||||
(adapters ?? []).map((adapter) =>
|
||||
makeRow(adapterDisplayName(adapter), adapterValue(adapter), {
|
||||
tone: adapterTone(adapter),
|
||||
wrapRow(adapterDisplayName(adapter), adapterValue(adapter), {
|
||||
tone: poweredOff ? 'default' : adapterTone(adapter),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -246,7 +267,7 @@ const toolsSummary = (tools?: ResourceVMwareTools): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const toolsRows = (tools?: ResourceVMwareTools): DetailRow[] => {
|
||||
const toolsRows = (tools: ResourceVMwareTools | undefined, poweredOff: boolean): DetailRow[] => {
|
||||
if (!tools) return [];
|
||||
const versionStatus = asTrimmedString(tools.versionStatus).toUpperCase();
|
||||
const versionAttention = Boolean(versionStatus) && !['CURRENT', 'OK'].includes(versionStatus);
|
||||
@@ -258,7 +279,8 @@ const toolsRows = (tools?: ResourceVMwareTools): DetailRow[] => {
|
||||
return compactRows([
|
||||
makeRow('Run state', formatEnumLabel(tools.runState), {
|
||||
tone: getWarningTone(
|
||||
Boolean(asTrimmedString(tools.runState)) &&
|
||||
!poweredOff &&
|
||||
Boolean(asTrimmedString(tools.runState)) &&
|
||||
!['RUNNING', 'STARTED'].includes(asTrimmedString(tools.runState).toUpperCase()),
|
||||
),
|
||||
}),
|
||||
@@ -313,7 +335,7 @@ const virtualDiskValue = (disk: ResourceVMwareVirtualDisk): string => {
|
||||
const virtualDiskRows = (disks: ResourceVMwareVirtualDisk[] | undefined): DetailRow[] =>
|
||||
compactRows(
|
||||
(disks ?? []).map((disk) =>
|
||||
makeRow(virtualDiskDisplayName(disk), virtualDiskValue(disk), { tone: 'default' }),
|
||||
wrapRow(virtualDiskDisplayName(disk), virtualDiskValue(disk), { tone: 'default' }),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -347,11 +369,41 @@ const buildSignalValue = (count: number | undefined, label: string, summary?: st
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const getStatusTone = (status?: string | null): DetailValueTone => {
|
||||
// vCenter's overall status colours, in plain words: red is a problem, yellow
|
||||
// may be one, green is fine and gray is not known.
|
||||
const OVERALL_STATUS_PRESENTATION: Record<string, { label: string; tone: DetailValueTone }> = {
|
||||
red: { label: 'Problem (red)', tone: 'danger' },
|
||||
yellow: { label: 'Warning (yellow)', tone: 'warning' },
|
||||
green: { label: 'OK (green)', tone: 'success' },
|
||||
gray: { label: 'Unknown (gray)', tone: 'default' },
|
||||
};
|
||||
|
||||
const overallStatusRow = (status?: string | null): DetailRow | null => {
|
||||
const normalized = asTrimmedString(status).toLowerCase();
|
||||
if (normalized === 'red') return 'warning';
|
||||
if (normalized) return 'accent';
|
||||
return 'default';
|
||||
const presentation = OVERALL_STATUS_PRESENTATION[normalized];
|
||||
return presentation
|
||||
? makeRow('Overall status', presentation.label, { tone: presentation.tone })
|
||||
: makeRow('Overall status', asTrimmedString(status));
|
||||
};
|
||||
|
||||
// A host that vCenter cannot reach is the reason its row reads offline.
|
||||
const connectionStateTone = (state?: string): DetailValueTone => {
|
||||
const normalized = asTrimmedString(state)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z]/g, '');
|
||||
return normalized === 'disconnected' || normalized === 'notresponding' ? 'warning' : 'default';
|
||||
};
|
||||
|
||||
// Datastores report "normal" when not in maintenance; anything else
|
||||
// (entering or in maintenance) means VMs cannot rely on it.
|
||||
const maintenanceRow = (mode?: string): DetailRow | null => {
|
||||
const raw = asTrimmedString(mode);
|
||||
if (!raw) return null;
|
||||
if (raw.toLowerCase() === 'normal') return makeRow('Maintenance', 'No');
|
||||
const words = raw.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ');
|
||||
return makeRow('Maintenance', words.charAt(0).toUpperCase() + words.slice(1).toLowerCase(), {
|
||||
tone: 'warning',
|
||||
});
|
||||
};
|
||||
|
||||
const getAccentTone = (hasAccent: boolean): DetailValueTone => (hasAccent ? 'accent' : 'default');
|
||||
@@ -416,38 +468,37 @@ export const buildVMwareDetailSections = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const poweredOff = isPoweredOff(vmware);
|
||||
const stateRows = compactRows([
|
||||
makeRow('Connection', asTrimmedString(vmware.connectionName)),
|
||||
makeRow('vCenter', asTrimmedString(vmware.vcenterHost)),
|
||||
wrapRow('Connection', asTrimmedString(vmware.connectionName)),
|
||||
wrapRow('vCenter', asTrimmedString(vmware.vcenterHost)),
|
||||
makeRow('Entity', vmwareEntityLabel(vmware.entityType)),
|
||||
makeRow('Overall status', asTrimmedString(vmware.overallStatus), {
|
||||
tone: getStatusTone(vmware.overallStatus),
|
||||
}),
|
||||
overallStatusRow(vmware.overallStatus),
|
||||
// Power state is already conveyed by the workload row's status dot and
|
||||
// the SYSTEM card. We only resurface Connection state here because it
|
||||
// matters when an ESXi host is disconnected and the row dot alone can't
|
||||
// tell you why.
|
||||
makeRow('Connection state', formatEnumLabel(vmware.connectionState)),
|
||||
makeRow('Connection state', formatEnumLabel(vmware.connectionState), {
|
||||
tone: connectionStateTone(vmware.connectionState),
|
||||
}),
|
||||
makeRow('Datastore type', asTrimmedString(vmware.datastoreType)),
|
||||
makeRow('Accessible', formatBoolLabel(vmware.datastoreAccessible), {
|
||||
tone: getWarningTone(vmware.datastoreAccessible === false),
|
||||
}),
|
||||
makeRow('Shared access', formatBoolLabel(vmware.multipleHostAccess)),
|
||||
makeRow('Maintenance', asTrimmedString(vmware.maintenanceMode), {
|
||||
tone: getWarningTone(Boolean(asTrimmedString(vmware.maintenanceMode))),
|
||||
}),
|
||||
maintenanceRow(vmware.maintenanceMode),
|
||||
makeRow('Network type', formatEnumLabel(vmware.networkType)),
|
||||
]);
|
||||
|
||||
const placementRows = compactRows([
|
||||
makeRow('Datacenter', asTrimmedString(vmware.datacenterName)),
|
||||
makeRow('Cluster', asTrimmedString(vmware.clusterName)),
|
||||
wrapRow('Datacenter', asTrimmedString(vmware.datacenterName)),
|
||||
wrapRow('Cluster', asTrimmedString(vmware.clusterName)),
|
||||
makeRow('Cluster services', formatVmwareClusterServices(vmware)),
|
||||
makeRow('Compute resource', asTrimmedString(vmware.computeResourceName)),
|
||||
makeRow('Folder', asTrimmedString(vmware.folderName)),
|
||||
makeRow('Resource pool', asTrimmedString(vmware.resourcePoolName)),
|
||||
makeRow('Runtime host', asTrimmedString(vmware.runtimeHostName)),
|
||||
makeRow('Datastores', (vmware.datastoreNames ?? []).filter(Boolean).join(', ')),
|
||||
wrapRow('Compute resource', asTrimmedString(vmware.computeResourceName)),
|
||||
wrapRow('Folder', asTrimmedString(vmware.folderName)),
|
||||
wrapRow('Resource pool', asTrimmedString(vmware.resourcePoolName)),
|
||||
wrapRow('Runtime host', asTrimmedString(vmware.runtimeHostName)),
|
||||
wrapRow('Datastores', (vmware.datastoreNames ?? []).filter(Boolean).join(', ')),
|
||||
]);
|
||||
|
||||
// UUID fields (host / instance / BIOS) and datastoreUrl are API-shaped
|
||||
@@ -456,19 +507,19 @@ export const buildVMwareDetailSections = (
|
||||
// human-readable.
|
||||
const guestRows = compactRows([
|
||||
makeRow('Guest OS', asTrimmedString(vmware.guestOsFamily)),
|
||||
makeRow('Guest hostname', asTrimmedString(vmware.guestHostname)),
|
||||
makeRow('Guest IPs', (vmware.guestIpAddresses ?? []).filter(Boolean).join(', ')),
|
||||
wrapRow('Guest hostname', asTrimmedString(vmware.guestHostname)),
|
||||
wrapRow('Guest IPs', (vmware.guestIpAddresses ?? []).filter(Boolean).join(', ')),
|
||||
]);
|
||||
|
||||
const networkRows =
|
||||
resourceType === 'vm'
|
||||
? networkAdapterRows(vmware.networkAdapters)
|
||||
? networkAdapterRows(vmware.networkAdapters, poweredOff)
|
||||
: compactRows([
|
||||
makeRow('Hosts', summarizeList(vmware.networkHostNames)),
|
||||
makeRow('VMs', summarizeList(vmware.networkVmNames)),
|
||||
wrapRow('Hosts', summarizeList(vmware.networkHostNames)),
|
||||
wrapRow('VMs', summarizeList(vmware.networkVmNames)),
|
||||
]);
|
||||
const vmwareHardwareRows = resourceType === 'vm' ? hardwareRows(vmware) : [];
|
||||
const vmwareToolsRows = resourceType === 'vm' ? toolsRows(vmware.tools) : [];
|
||||
const vmwareToolsRows = resourceType === 'vm' ? toolsRows(vmware.tools, poweredOff) : [];
|
||||
const diskRows = resourceType === 'vm' ? virtualDiskRows(vmware.virtualDisks) : [];
|
||||
|
||||
const signalRows = compactRows([
|
||||
@@ -477,6 +528,7 @@ export const buildVMwareDetailSections = (
|
||||
buildSignalValue(vmware.activeAlarmCount, 'alarm', vmware.activeAlarmSummary),
|
||||
{
|
||||
tone: getWarningTone((vmware.activeAlarmCount ?? 0) > 0),
|
||||
wrap: true,
|
||||
},
|
||||
),
|
||||
makeRow('Tasks', buildSignalValue(vmware.recentTaskCount, 'task', vmware.recentTaskSummary), {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Component, Show, createMemo, createSignal } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { ResourceOperatorStateSection } from '@/components/Infrastructure/ResourceOperatorStateSection';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
|
||||
import { Subtabs } from '@/components/shared/Subtabs';
|
||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
||||
import {
|
||||
extractPhysicalDiskPresentationData,
|
||||
getPhysicalDiskHealthIndicator,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { DiskDrawerOverview } from './DiskDrawerOverview';
|
||||
|
||||
interface DiskDrawerProps {
|
||||
disk: Resource;
|
||||
/** Alert override keys of the machine whose agent reports the disk. */
|
||||
alertResourceIds?: string[];
|
||||
/** The disk's own open alerts. */
|
||||
alerts?: Alert[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DiskDrawerTab = 'overview' | 'manage';
|
||||
|
||||
const DiskDrawerContent: Component<DiskDrawerProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal<DiskDrawerTab>('overview');
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
// Judged under the same thresholds as the disk's row, so the drawer's
|
||||
// verdict and dot match its Health cell.
|
||||
const diskData = createMemo(() =>
|
||||
extractPhysicalDiskPresentationData(
|
||||
props.disk,
|
||||
getDiskTemperatureThresholds,
|
||||
props.alertResourceIds,
|
||||
),
|
||||
);
|
||||
const title = () => diskData().model || props.disk.name || 'Unknown Disk';
|
||||
const headingId = () => `disk-drawer-heading-${props.disk.id}`;
|
||||
const headerIndicator = createMemo(() => getPhysicalDiskHealthIndicator(diskData()));
|
||||
|
||||
return (
|
||||
<section class="space-y-3" aria-labelledby={headingId()} data-testid="disk-drawer">
|
||||
<ObjectDrawerHeader collapseLabel={`Collapse ${title()} details`} onCollapse={props.onClose}>
|
||||
<DrawerSubjectHeading
|
||||
headingId={headingId()}
|
||||
title={title()}
|
||||
statusVariant={headerIndicator().variant}
|
||||
statusLabel={headerIndicator().label}
|
||||
/>
|
||||
</ObjectDrawerHeader>
|
||||
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Disk drawer sections"
|
||||
value={activeTab()}
|
||||
onChange={(value) => setActiveTab(value as DiskDrawerTab)}
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'manage', label: 'Manage' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps.
|
||||
overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
|
||||
<div class={`[overflow-anchor:none] ${activeTab() === 'overview' ? '' : 'hidden'}`}>
|
||||
<DiskDrawerOverview
|
||||
disk={props.disk}
|
||||
diskData={diskData()}
|
||||
alerts={props.alerts}
|
||||
historyRange={historyRange()}
|
||||
onHistoryRangeChange={setHistoryRange}
|
||||
historyEnabled={activeTab() === 'overview'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={`[overflow-anchor:none] ${activeTab() === 'manage' ? '' : 'hidden'}`}>
|
||||
<Show when={activeTab() === 'manage'}>
|
||||
<div data-testid="disk-manage-tab">
|
||||
<ResourceOperatorStateSection
|
||||
resourceId={props.disk.id}
|
||||
resourceType={props.disk.type}
|
||||
platformType={props.disk.platformType}
|
||||
capabilities={props.disk.capabilities}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// Another disk must not inherit this disk's tab, range or operator form.
|
||||
export const DiskDrawer: Component<DiskDrawerProps> = (props) => (
|
||||
<Show when={props.disk.id} keyed>
|
||||
{(_diskId) => <DiskDrawerContent {...props} />}
|
||||
</Show>
|
||||
);
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Component, For, Show, Suspense, createMemo, lazy } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import type { DetailSection } from '@/components/shared/DetailSectionTable';
|
||||
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
|
||||
import {
|
||||
TechnicalDetailsDisclosure,
|
||||
TechnicalDetailsSection,
|
||||
} from '@/components/shared/TechnicalDetailsDisclosure';
|
||||
import { DrawerExceptionList } from '@/components/Workloads/DrawerExceptionList';
|
||||
import {
|
||||
getAlertStatedRowLabels,
|
||||
getDrawerChartAlerts,
|
||||
partitionDrawerExceptionRows,
|
||||
} from '@/components/Workloads/drawerOverviewModel';
|
||||
import {
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
type GuestDrawerHistoryTarget,
|
||||
} from '@/components/Workloads/guestDrawerModel';
|
||||
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
|
||||
import { getAlertAttentionCopy } from '@/features/alerts/metricAlertPresentation';
|
||||
import {
|
||||
buildDiskDrawerDetailSections,
|
||||
buildDiskDrawerHealthSection,
|
||||
getDiskDrawerCurrentMetrics,
|
||||
getDiskDrawerDeferredMetrics,
|
||||
getDiskDrawerFacts,
|
||||
getDiskDrawerHistoryGroups,
|
||||
getDiskDrawerNotices,
|
||||
isDiskDrawerIOAvailable,
|
||||
} from '@/features/storageBackups/diskDrawerPresentation';
|
||||
import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ALERTS_EMPTY_STATE } from '@/utils/alertOverviewPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { resolvePhysicalDiskHistoryResourceId } from './diskResourceUtils';
|
||||
|
||||
// Loaded once a disk drawer opens, so the Storage table chunk does not carry it.
|
||||
const GuestDrawerHistory = lazy(() =>
|
||||
import('@/components/Workloads/GuestDrawerHistory').then(({ GuestDrawerHistory }) => ({
|
||||
default: GuestDrawerHistory,
|
||||
})),
|
||||
);
|
||||
|
||||
interface DiskDrawerOverviewProps {
|
||||
disk: Resource;
|
||||
/** The disk's presentation, judged under its reporting machine's thresholds. */
|
||||
diskData: PhysicalDiskPresentationData;
|
||||
/** The disk's own open alerts. */
|
||||
alerts?: Alert[];
|
||||
historyRange?: HistoryTimeRange;
|
||||
onHistoryRangeChange?: (range: HistoryTimeRange) => void;
|
||||
/** True while Overview is the visible tab; hidden charts do not poll. */
|
||||
historyEnabled?: boolean;
|
||||
}
|
||||
|
||||
const cleanText = (value: string | null | undefined): string => (value || '').trim();
|
||||
|
||||
/**
|
||||
* The disk drawer's Overview: its alerts, what is worth checking, its
|
||||
* temperature, I/O and wear history, a facts strip, the SMART health card,
|
||||
* then identity, every SMART attribute and the I/O counters behind a closed
|
||||
* Details disclosure.
|
||||
*/
|
||||
export const DiskDrawerOverview: Component<DiskDrawerOverviewProps> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
|
||||
// Snapshot replacement keeps the same coordinates; only a real change of
|
||||
// disk identity starts another history read.
|
||||
const historyResourceId = createMemo(() => resolvePhysicalDiskHistoryResourceId(props.disk));
|
||||
const historyTarget = createMemo((): GuestDrawerHistoryTarget | null => {
|
||||
const resourceId = historyResourceId();
|
||||
return resourceId ? { resourceType: 'disk', resourceId } : null;
|
||||
});
|
||||
const historyGroups = createMemo(() =>
|
||||
getDiskDrawerHistoryGroups(props.diskData, isDiskDrawerIOAvailable(props.diskData)),
|
||||
);
|
||||
const chartAlerts = createMemo(() =>
|
||||
getDrawerChartAlerts(
|
||||
props.alerts,
|
||||
historyGroups().flatMap((group) => group.series.map((series) => series.metric)),
|
||||
),
|
||||
);
|
||||
|
||||
const healthSection = createMemo(() => buildDiskDrawerHealthSection(props.diskData));
|
||||
const detailSections = createMemo(() =>
|
||||
buildDiskDrawerDetailSections(props.diskData, props.disk),
|
||||
);
|
||||
const allSections = (): DetailSection[] => [healthSection(), ...detailSections()];
|
||||
// A warning or danger fact moves in front; it is not repeated where it
|
||||
// lives. A fact one of the disk's own alerts already states stays put, so
|
||||
// the alert above is not restated beneath it.
|
||||
const inventory = createMemo(() =>
|
||||
partitionDrawerExceptionRows(allSections(), getAlertStatedRowLabels(props.alerts ?? [])),
|
||||
);
|
||||
const exceptionRows = () => inventory().lifted;
|
||||
const frontSections = createMemo(() =>
|
||||
inventory().details.filter((section) => section.testId === healthSection().testId),
|
||||
);
|
||||
const remainingDetailSections = createMemo(() =>
|
||||
inventory().details.filter((section) => section.testId !== healthSection().testId),
|
||||
);
|
||||
const notices = createMemo(() => getDiskDrawerNotices(props.diskData));
|
||||
const facts = createMemo(() => getDiskDrawerFacts(props.diskData, props.disk));
|
||||
|
||||
return (
|
||||
<div class="space-y-3" data-testid="disk-drawer-overview">
|
||||
<DrawerAttentionSection
|
||||
items={(props.alerts ?? []).map((alert) => ({
|
||||
id: alert.id,
|
||||
...getAlertAttentionCopy(alert, now),
|
||||
subject: cleanText(alert.resourceName) || props.diskData.model || props.disk.name,
|
||||
metric: alertTypeDisplayLabel(alert.type),
|
||||
severity: alert.level,
|
||||
acknowledged: alert.acknowledged,
|
||||
}))}
|
||||
/>
|
||||
<Show when={(props.alerts ?? []).length === 0}>
|
||||
<p data-testid="drawer-no-attention" class="text-xs font-medium text-base-content">
|
||||
{ALERTS_EMPTY_STATE}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<DrawerExceptionList testId="disk-drawer-exceptions" rows={exceptionRows()} />
|
||||
|
||||
{/* What the collector could not read says so once, above the charts it
|
||||
affects, rather than as an empty chart. */}
|
||||
<Show when={notices().length > 0}>
|
||||
<div data-testid="disk-drawer-notices" role="status" class="space-y-0.5 text-xs text-muted">
|
||||
<For each={notices()}>{(message) => <p>{message}</p>}</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={historyTarget()}
|
||||
fallback={
|
||||
<p data-testid="disk-drawer-history-unavailable" class="text-xs text-muted">
|
||||
History is unavailable until Pulse can resolve a stable identity for this disk.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div class="h-48" aria-hidden="true" />}>
|
||||
<GuestDrawerHistory
|
||||
target={historyTarget()}
|
||||
range={props.historyRange ?? GUEST_DRAWER_HISTORY_DEFAULT_RANGE}
|
||||
onRangeChange={props.onHistoryRangeChange}
|
||||
enabled={props.historyEnabled ?? true}
|
||||
groups={historyGroups()}
|
||||
currentMetrics={getDiskDrawerCurrentMetrics(props.diskData)}
|
||||
deferredMetrics={getDiskDrawerDeferredMetrics(props.diskData)}
|
||||
alerts={chartAlerts()}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
|
||||
<Show when={facts().length > 0}>
|
||||
<div
|
||||
data-testid="disk-drawer-facts"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"
|
||||
>
|
||||
<For each={facts()}>
|
||||
{(fact) => <span class="max-w-full whitespace-normal wrap-anywhere">{fact}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={frontSections().length > 0}>
|
||||
<TechnicalDetailsSection dataTestId="disk-drawer-health-card" sections={frontSections()} />
|
||||
</Show>
|
||||
|
||||
<Show when={remainingDetailSections().length > 0}>
|
||||
<TechnicalDetailsDisclosure
|
||||
dataTestId="disk-technical-details"
|
||||
title="Details"
|
||||
keepMounted
|
||||
subtitle={remainingDetailSections()
|
||||
.map((section) => section.label.toLowerCase())
|
||||
.join(', ')}
|
||||
class="border-t border-border pt-2"
|
||||
contentClass="mt-2"
|
||||
sections={remainingDetailSections()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildSummaryDisclosureControlsId,
|
||||
createSummaryInteractiveRowPreviewHandlers,
|
||||
} from '@/components/shared/summaryInteractionA11y';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
||||
import { resolvePhysicalDiskMetricResourceId } from '@/features/storageBackups/storageMetricsIdentity';
|
||||
import {
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
PHYSICAL_DISK_COL_ROLE_CLASS,
|
||||
PHYSICAL_DISK_COL_SIZE_CLASS,
|
||||
PHYSICAL_DISK_COL_TEMP_CLASS,
|
||||
PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS,
|
||||
PHYSICAL_DISK_EMPTY_CARD_CLASS,
|
||||
PHYSICAL_DISK_EMPTY_FALLBACK_CLASS,
|
||||
PHYSICAL_DISK_EMPTY_FALLBACK_TEXT_CLASS,
|
||||
@@ -91,16 +91,19 @@ import {
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
|
||||
import { PlatformWindowedRows } from '@/features/platformPage/sharedPlatformPage';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { StorageHealthFilter } from '@/features/storageBackups/models';
|
||||
import { getStorageSourceOption } from '@/utils/storageSources';
|
||||
import { DiskDetail } from './DiskDetail';
|
||||
import { DiskDrawer } from './DiskDrawer';
|
||||
import { useDiskListModel } from './useDiskListModel';
|
||||
|
||||
interface DiskListProps {
|
||||
disks: Resource[];
|
||||
/** Alert override keys of the machine reporting each disk; absent means none. */
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
/** A disk's own open alerts, for its drawer. */
|
||||
getDiskAlerts?: (disk: Resource) => Alert[];
|
||||
nodes: Resource[];
|
||||
selectedNode: string | null;
|
||||
sourceFilter?: string;
|
||||
@@ -114,6 +117,9 @@ interface DiskListProps {
|
||||
onHoverChange?: (diskId: string | null) => void;
|
||||
}
|
||||
|
||||
// The disk table's columns: the colgroup below declares one <col> for each.
|
||||
const PHYSICAL_DISK_TABLE_COLUMN_COUNT = 9;
|
||||
|
||||
export const DiskList: Component<DiskListProps> = (props) => {
|
||||
const tableWidth = useObservedElementWidth();
|
||||
const layoutMode = createMemo(() =>
|
||||
@@ -567,19 +573,18 @@ export const DiskList: Component<DiskListProps> = (props) => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Show when={isSelected()}>
|
||||
<TableRow data-inline-detail-for={summarySeriesId()}>
|
||||
<TableCell
|
||||
id={detailControlsId()}
|
||||
colSpan={9}
|
||||
class={PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS}
|
||||
>
|
||||
<DiskDetail
|
||||
disk={disk}
|
||||
nodes={props.nodes}
|
||||
alertResourceIds={props.getDiskAlertResourceIds?.(disk)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<InlineDetailTableRow
|
||||
cellId={detailControlsId()}
|
||||
colspan={PHYSICAL_DISK_TABLE_COLUMN_COUNT}
|
||||
data-inline-detail-for={summarySeriesId()}
|
||||
>
|
||||
<DiskDrawer
|
||||
disk={disk}
|
||||
alertResourceIds={props.getDiskAlertResourceIds?.(disk)}
|
||||
alerts={props.getDiskAlerts?.(disk)}
|
||||
onClose={() => model.toggleSelectedDisk(disk)}
|
||||
/>
|
||||
</InlineDetailTableRow>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
activeSummaryStorageResourceId,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
getDiskAlerts,
|
||||
nodes,
|
||||
groupedRecords,
|
||||
expandedGroups,
|
||||
@@ -68,6 +69,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
nodeOnlineByLabel,
|
||||
highlightedRecordId,
|
||||
getRecordAlertState,
|
||||
getRecordAlerts,
|
||||
hoveredStorageResourceId,
|
||||
isLoadingPools,
|
||||
selectedDiskId,
|
||||
@@ -139,6 +141,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
view={view}
|
||||
physicalDisks={physicalDisks}
|
||||
getDiskAlertResourceIds={getDiskAlertResourceIds}
|
||||
getDiskAlerts={getDiskAlerts}
|
||||
nodes={nodes}
|
||||
sourceFilter={sourceFilter}
|
||||
healthFilter={healthFilter}
|
||||
@@ -157,6 +160,7 @@ const Storage: Component<StorageProps> = (props) => {
|
||||
nodeOnlineByLabel={nodeOnlineByLabel}
|
||||
highlightedRecordId={highlightedRecordId}
|
||||
getRecordAlertState={getRecordAlertState}
|
||||
getRecordAlerts={getRecordAlerts}
|
||||
isLoadingPools={isLoadingPools}
|
||||
clearPinnedSummaryScope={clearPinnedSummaryScope}
|
||||
highlightedSummaryResourceId={activeSummaryStorageResourceId}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DiskList } from '@/components/Storage/DiskList';
|
||||
import StoragePoolsTable from '@/components/Storage/StoragePoolsTable';
|
||||
import { STORAGE_CONTENT_CARD_BODY_CLASS } from '@/features/storageBackups/storagePagePresentation';
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { StorageHealthFilter } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
@@ -17,6 +18,8 @@ type StorageContentCardProps = {
|
||||
view: () => StorageView;
|
||||
physicalDisks: () => Resource[];
|
||||
getDiskAlertResourceIds: PhysicalDiskAlertResourceIdResolver;
|
||||
/** A disk's own open alerts, for its drawer. */
|
||||
getDiskAlerts?: (disk: Resource) => Alert[];
|
||||
nodes: () => Resource[];
|
||||
sourceFilter: () => string;
|
||||
healthFilter: () => StorageHealthFilter;
|
||||
@@ -35,6 +38,8 @@ type StorageContentCardProps = {
|
||||
nodeOnlineByLabel: () => Map<string, boolean>;
|
||||
highlightedRecordId: () => string | null;
|
||||
getRecordAlertState: (recordId: string) => StorageAlertRowState;
|
||||
/** A pool's open alerts, for its drawer: the set that tints its row. */
|
||||
getRecordAlerts?: (recordId: string) => Alert[];
|
||||
isLoadingPools: () => boolean;
|
||||
clearPinnedSummaryScope: () => void;
|
||||
highlightedSummaryResourceId: () => string | null;
|
||||
@@ -70,6 +75,7 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
|
||||
<DiskList
|
||||
disks={props.physicalDisks()}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
getDiskAlerts={props.getDiskAlerts}
|
||||
nodes={props.nodes()}
|
||||
sourceFilter={props.sourceFilter()}
|
||||
healthFilter={props.healthFilter()}
|
||||
@@ -99,6 +105,7 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
|
||||
nodeOnlineByLabel={props.nodeOnlineByLabel()}
|
||||
highlightedRecordId={props.highlightedRecordId()}
|
||||
getRecordAlertState={props.getRecordAlertState}
|
||||
getRecordAlerts={props.getRecordAlerts}
|
||||
isLoading={props.isLoadingPools()}
|
||||
highlightedSummarySeriesId={props.highlightedSummaryResourceId()}
|
||||
onHoverChange={props.setHoveredStorageResourceId}
|
||||
|
||||
@@ -1,316 +1,239 @@
|
||||
import { Component, For, Show, createEffect, createMemo } from 'solid-js';
|
||||
import { FormSelect } from '@/components/shared/FormSelect';
|
||||
import { filterSelectClass } from '@/components/shared/FilterToolbar';
|
||||
import { HistoryChart } from '@/components/shared/HistoryChart';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { Component, For, Show, Suspense, createMemo, lazy } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { maxHistoryDays } from '@/stores/license';
|
||||
import {
|
||||
getUnlockedHistoryRangeOptions,
|
||||
resolveHistoryRangeWithinLimit,
|
||||
} from '@/components/Storage/historyRangeAccess';
|
||||
compactDetailRows,
|
||||
makeDetailRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import {
|
||||
TechnicalDetailsDisclosure,
|
||||
TechnicalDetailsSection,
|
||||
} from '@/components/shared/TechnicalDetailsDisclosure';
|
||||
import { DrawerExceptionList } from '@/components/Workloads/DrawerExceptionList';
|
||||
import { getDrawerChartAlerts } from '@/components/Workloads/drawerOverviewModel';
|
||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
||||
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
|
||||
import { getAlertAttentionCopy } from '@/features/alerts/metricAlertPresentation';
|
||||
import {
|
||||
getLinkedDiskHealthDotVariant,
|
||||
getLinkedDiskTemperatureSeverity,
|
||||
getLinkedDiskTemperatureTextClass,
|
||||
} from '@/features/storageBackups/diskDetailPresentation';
|
||||
import { PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS } from '@/features/storageBackups/diskPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import {
|
||||
STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS,
|
||||
getZfsDeviceStateTextClass,
|
||||
getZfsErrorTextClass,
|
||||
getZfsScanTextClass,
|
||||
} from '@/features/storageBackups/storagePoolDetailPresentation';
|
||||
import {
|
||||
STORAGE_DETAIL_CARD_CLASS,
|
||||
STORAGE_DETAIL_CELL_CLASS,
|
||||
STORAGE_DETAIL_CONFIG_GRID_CLASS,
|
||||
STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS,
|
||||
STORAGE_DETAIL_HEADER_ROW_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISK_PATH_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISK_ROW_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISKS_LIST_CLASS,
|
||||
STORAGE_DETAIL_MUTED_TEXT_CLASS,
|
||||
STORAGE_DETAIL_ROOT_GRID_CLASS,
|
||||
STORAGE_DETAIL_ROW_CLASS,
|
||||
STORAGE_DETAIL_SECTION_TITLE_CLASS,
|
||||
STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS,
|
||||
} from '@/features/storageBackups/detailPresentation';
|
||||
PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS,
|
||||
type PhysicalDiskAlertResourceIdResolver,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { StoragePoolDetailLinkedDisk } from '@/features/storageBackups/storagePoolDetailPresentation';
|
||||
import {
|
||||
STORAGE_POOL_DRAWER_CHARTED_METRICS,
|
||||
STORAGE_POOL_DRAWER_HISTORY_GROUPS,
|
||||
buildStoragePoolDrawerDetailSections,
|
||||
buildStoragePoolDrawerExceptionRows,
|
||||
buildStoragePoolDrawerZfsSections,
|
||||
getStoragePoolDrawerCurrentMetrics,
|
||||
getStoragePoolDrawerDeferredMetrics,
|
||||
getStoragePoolDrawerFacts,
|
||||
getStoragePoolLinkedDiskSummary,
|
||||
} from '@/features/storageBackups/storagePoolDrawerPresentation';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ALERTS_EMPTY_STATE } from '@/utils/alertOverviewPresentation';
|
||||
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
|
||||
import { formatTemperature } from '@/utils/temperature';
|
||||
import { StorageDetailKeyValueRow } from './StorageDetailKeyValueRow';
|
||||
import { useStoragePoolDetailModel } from './useStoragePoolDetailModel';
|
||||
|
||||
// Loaded once a pool drawer opens, so the Storage table chunk does not carry it.
|
||||
const GuestDrawerHistory = lazy(() =>
|
||||
import('@/components/Workloads/GuestDrawerHistory').then(({ GuestDrawerHistory }) => ({
|
||||
default: GuestDrawerHistory,
|
||||
})),
|
||||
);
|
||||
|
||||
interface StoragePoolDetailProps {
|
||||
record: StorageRecord;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
summarySeriesId: string;
|
||||
controlsId?: string;
|
||||
/** The pool's own open alerts, matched the way its row matches them. */
|
||||
alerts?: Alert[];
|
||||
historyRange?: HistoryTimeRange;
|
||||
onHistoryRangeChange?: (range: HistoryTimeRange) => void;
|
||||
/** True while Overview is the visible tab; hidden charts do not poll. */
|
||||
historyEnabled?: boolean;
|
||||
}
|
||||
|
||||
const cleanText = (value: string | null | undefined): string => (value || '').trim();
|
||||
|
||||
/**
|
||||
* The pool drawer's Overview: its alerts, what is worth checking, its
|
||||
* capacity trend, a facts strip, its ZFS devices and linked disks, then the
|
||||
* configuration, topology and datasets behind a closed Details disclosure.
|
||||
*/
|
||||
export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
|
||||
const now = useRelativeTimeNow();
|
||||
const { getDiskTemperatureThresholds } = useAlertsActivation();
|
||||
const {
|
||||
chartRange,
|
||||
setChartRange,
|
||||
chartTarget,
|
||||
configRows,
|
||||
topologyRows,
|
||||
zfsSummary,
|
||||
linkedDisks,
|
||||
} = useStoragePoolDetailModel({
|
||||
record: () => props.record,
|
||||
physicalDisks: () => props.physicalDisks,
|
||||
getDiskAlertResourceIds: props.getDiskAlertResourceIds,
|
||||
});
|
||||
const rangeOptions = createMemo(() =>
|
||||
getUnlockedHistoryRangeOptions(STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS, maxHistoryDays()),
|
||||
const { historyTarget, configRows, topologyRows, zfsSummary, linkedDisks } =
|
||||
useStoragePoolDetailModel({
|
||||
record: () => props.record,
|
||||
physicalDisks: () => props.physicalDisks,
|
||||
getDiskAlertResourceIds: props.getDiskAlertResourceIds,
|
||||
});
|
||||
|
||||
const diskThresholds = (disk: StoragePoolDetailLinkedDisk) =>
|
||||
getDiskTemperatureThresholds(disk.diskType, disk.alertResourceIds);
|
||||
|
||||
const exceptionRows = createMemo(() =>
|
||||
buildStoragePoolDrawerExceptionRows({
|
||||
record: props.record,
|
||||
zfsSummary: zfsSummary(),
|
||||
linkedDisks: linkedDisks(),
|
||||
getDiskHeat: (disk) =>
|
||||
getLinkedDiskTemperatureSeverity(disk.temperature, diskThresholds(disk)),
|
||||
}),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const nextRange = resolveHistoryRangeWithinLimit(
|
||||
chartRange(),
|
||||
STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS,
|
||||
maxHistoryDays(),
|
||||
);
|
||||
if (nextRange !== chartRange()) {
|
||||
setChartRange(nextRange);
|
||||
}
|
||||
});
|
||||
const linkedDiskRow = (disk: StoragePoolDetailLinkedDisk): DetailRow | null =>
|
||||
makeDetailRow(disk.devPath, getStoragePoolLinkedDiskSummary(disk) || disk.model, {
|
||||
layout: 'stacked',
|
||||
wrap: true,
|
||||
valueContent: (
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-left font-normal whitespace-normal">
|
||||
<StatusDot variant={getLinkedDiskHealthDotVariant(disk.hasIssue)} size="sm" ariaHidden />
|
||||
<span class="wrap-break-word text-base-content">{disk.model}</span>
|
||||
<Show when={disk.role}>
|
||||
<span class="text-muted">{disk.role}</span>
|
||||
</Show>
|
||||
<Show when={disk.sizeLabel}>
|
||||
<span class="text-muted">{disk.sizeLabel}</span>
|
||||
</Show>
|
||||
<Show when={disk.state}>
|
||||
<span class="text-muted">{disk.state}</span>
|
||||
</Show>
|
||||
<Show when={disk.ioLabel}>
|
||||
<span class="text-muted">{disk.ioLabel}</span>
|
||||
</Show>
|
||||
<Show when={disk.spunDown}>
|
||||
<span class="text-muted">spun down</span>
|
||||
</Show>
|
||||
<Show when={disk.errorCount > 0}>
|
||||
<span class="font-semibold text-amber-700 dark:text-amber-300">
|
||||
{disk.errorCount.toLocaleString()} errors
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={disk.temperature > 0}>
|
||||
<span
|
||||
class={`font-medium ${
|
||||
disk.temperatureLastKnownTitle
|
||||
? PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS
|
||||
: getLinkedDiskTemperatureTextClass(disk.temperature, diskThresholds(disk))
|
||||
}`}
|
||||
title={disk.temperatureLastKnownTitle}
|
||||
>
|
||||
{formatTemperature(disk.temperature)}
|
||||
<Show when={disk.temperatureLastKnownTitle}>
|
||||
<span class="sr-only">, last known</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
),
|
||||
});
|
||||
|
||||
// The pool's device tree and the physical disks behind it stay in front:
|
||||
// they answer "which disk" when Worth checking names a problem.
|
||||
const deviceSections = createMemo((): DetailSection[] => [
|
||||
...buildStoragePoolDrawerZfsSections(zfsSummary()),
|
||||
...(linkedDisks().length > 0
|
||||
? [
|
||||
{
|
||||
label: `Physical disks (${linkedDisks().length})`,
|
||||
testId: 'storage-pool-linked-disks',
|
||||
rows: compactDetailRows(linkedDisks().map(linkedDiskRow)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const detailSections = createMemo(() =>
|
||||
buildStoragePoolDrawerDetailSections({
|
||||
configRows: configRows(),
|
||||
topologyRows: topologyRows(),
|
||||
zfsSummary: zfsSummary(),
|
||||
}),
|
||||
);
|
||||
const detailSubtitle = () =>
|
||||
detailSections()
|
||||
.map((section) => section.label.replace(/\s*\(\d+\)$/, '').toLowerCase())
|
||||
.join(', ');
|
||||
|
||||
const facts = createMemo(() => getStoragePoolDrawerFacts(props.record));
|
||||
const chartAlerts = createMemo(() =>
|
||||
getDrawerChartAlerts(props.alerts, STORAGE_POOL_DRAWER_CHARTED_METRICS),
|
||||
);
|
||||
|
||||
return (
|
||||
<tr class={STORAGE_DETAIL_ROW_CLASS} data-inline-detail-for={props.summarySeriesId}>
|
||||
<td id={props.controlsId} colSpan={99} class={STORAGE_DETAIL_CELL_CLASS}>
|
||||
<div class="space-y-3">
|
||||
{/* The capacity trend leads: "is this pool filling up?" is answered
|
||||
before topology and devices. There is no separate History tab
|
||||
repeating it. */}
|
||||
<div class={STORAGE_DETAIL_CARD_CLASS} data-testid="storage-pool-capacity-trend">
|
||||
<div class={STORAGE_DETAIL_HEADER_ROW_CLASS}>
|
||||
<h4 class={STORAGE_DETAIL_SECTION_TITLE_CLASS}>Capacity Trend</h4>
|
||||
<FormSelect
|
||||
label="Capacity history range"
|
||||
labelClass="sr-only"
|
||||
fieldBaseClass="contents"
|
||||
value={chartRange()}
|
||||
onChange={(event) => setChartRange(event.currentTarget.value as HistoryTimeRange)}
|
||||
selectBaseClass={`${filterSelectClass} h-7 min-h-11 py-0 text-[11px] sm:min-h-0`}
|
||||
>
|
||||
<For each={rangeOptions()}>
|
||||
{(option) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</FormSelect>
|
||||
</div>
|
||||
<HistoryChart
|
||||
resourceType={chartTarget().resourceType}
|
||||
resourceId={chartTarget().resourceId || props.record.id}
|
||||
metric="usage"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
height={140}
|
||||
range={chartRange()}
|
||||
hideSelector
|
||||
compact
|
||||
hideLock
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-3" data-testid="storage-pool-drawer-overview">
|
||||
<DrawerAttentionSection
|
||||
items={(props.alerts ?? []).map((alert) => ({
|
||||
id: alert.id,
|
||||
...getAlertAttentionCopy(alert, now),
|
||||
subject: cleanText(alert.resourceName) || props.record.name,
|
||||
metric: alertTypeDisplayLabel(alert.type),
|
||||
severity: alert.level,
|
||||
acknowledged: alert.acknowledged,
|
||||
}))}
|
||||
/>
|
||||
<Show when={(props.alerts ?? []).length === 0}>
|
||||
<p data-testid="drawer-no-attention" class="text-xs font-medium text-base-content">
|
||||
{ALERTS_EMPTY_STATE}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class={STORAGE_DETAIL_ROOT_GRID_CLASS}>
|
||||
<Show when={topologyRows().length > 0}>
|
||||
<div class={STORAGE_DETAIL_CARD_CLASS}>
|
||||
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>Topology</h4>
|
||||
<div class={STORAGE_DETAIL_CONFIG_GRID_CLASS}>
|
||||
<For each={topologyRows()}>
|
||||
{(row) => <StorageDetailKeyValueRow label={row.label} value={row.value} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerExceptionList testId="storage-pool-drawer-exceptions" rows={exceptionRows()} />
|
||||
|
||||
<div class={STORAGE_DETAIL_CARD_CLASS}>
|
||||
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>Configuration</h4>
|
||||
<div class={STORAGE_DETAIL_CONFIG_GRID_CLASS}>
|
||||
<For each={configRows()}>
|
||||
{(row) => <StorageDetailKeyValueRow label={row.label} value={row.value} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<div class="h-48" aria-hidden="true" />}>
|
||||
<GuestDrawerHistory
|
||||
target={historyTarget()}
|
||||
range={props.historyRange ?? GUEST_DRAWER_HISTORY_DEFAULT_RANGE}
|
||||
onRangeChange={props.onHistoryRangeChange}
|
||||
enabled={props.historyEnabled ?? true}
|
||||
groups={STORAGE_POOL_DRAWER_HISTORY_GROUPS}
|
||||
currentMetrics={getStoragePoolDrawerCurrentMetrics(props.record)}
|
||||
deferredMetrics={getStoragePoolDrawerDeferredMetrics(props.record)}
|
||||
alerts={chartAlerts()}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
<Show when={zfsSummary()}>
|
||||
<div class={STORAGE_DETAIL_CARD_CLASS}>
|
||||
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>ZFS Pool</h4>
|
||||
<div class={STORAGE_DETAIL_CONFIG_GRID_CLASS}>
|
||||
<StorageDetailKeyValueRow label="State" value={zfsSummary()!.state} />
|
||||
<Show when={zfsSummary()!.scan}>
|
||||
<div class={STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS}>
|
||||
<span class={STORAGE_DETAIL_MUTED_TEXT_CLASS}>Scan: </span>
|
||||
<span class={getZfsScanTextClass()}>{zfsSummary()!.scan}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={zfsSummary()!.errorSummary}>
|
||||
<div class={`${STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS} ${getZfsErrorTextClass()}`}>
|
||||
{zfsSummary()!.errorSummary}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={zfsSummary()!.devices.length > 0}>
|
||||
<div class={`${STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS} space-y-0.5 pt-1`}>
|
||||
<For each={zfsSummary()!.devices}>
|
||||
{(device) => (
|
||||
<div class={STORAGE_DETAIL_LINKED_DISK_ROW_CLASS}>
|
||||
<span class={STORAGE_DETAIL_LINKED_DISK_PATH_CLASS} title={device.name}>
|
||||
{device.name}
|
||||
</span>
|
||||
<Show when={device.type}>
|
||||
<span class={STORAGE_DETAIL_MUTED_TEXT_CLASS}>{device.type}</span>
|
||||
</Show>
|
||||
<span class={getZfsDeviceStateTextClass(device.state)}>
|
||||
{device.state}
|
||||
</span>
|
||||
<Show when={device.errorSummary}>
|
||||
<span class={getZfsErrorTextClass()}>{device.errorSummary}</span>
|
||||
</Show>
|
||||
<Show when={device.message}>
|
||||
<span class={`${STORAGE_DETAIL_MUTED_TEXT_CLASS} italic`}>
|
||||
{device.message}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={zfsSummary()!.datasets.length > 0}>
|
||||
<div class={`${STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS} space-y-2 pt-2`}>
|
||||
<div class="text-xs font-medium text-base-content">
|
||||
Datasets ({zfsSummary()!.datasets.length})
|
||||
</div>
|
||||
<div class="table-scroll-shell overflow-x-auto rounded-sm border border-border">
|
||||
<table class="w-full min-w-0 table-fixed text-left text-xs">
|
||||
<thead class="bg-surface-alt text-muted">
|
||||
<tr>
|
||||
<th class="px-2 py-1.5 font-medium">Dataset</th>
|
||||
<th class="px-2 py-1.5 font-medium">Used</th>
|
||||
<th class="px-2 py-1.5 font-medium">Available</th>
|
||||
<th class="storage-dataset-referenced-column px-2 py-1.5 font-medium">
|
||||
Referenced
|
||||
</th>
|
||||
<th class="hidden px-2 py-1.5 font-medium sm:table-cell">
|
||||
Mountpoint
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={zfsSummary()!.datasets}>
|
||||
{(dataset) => (
|
||||
<tr class={STORAGE_DETAIL_ROW_CLASS}>
|
||||
<td
|
||||
class="max-w-64 truncate px-2 py-1.5 font-mono"
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 py-1.5 font-mono">
|
||||
{dataset.usedLabel}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-2 py-1.5 font-mono">
|
||||
{dataset.availableLabel}
|
||||
</td>
|
||||
<td class="storage-dataset-referenced-column whitespace-nowrap px-2 py-1.5 font-mono">
|
||||
{dataset.referencedLabel}
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-48 truncate px-2 py-1.5 text-muted sm:table-cell"
|
||||
title={dataset.mountpoint}
|
||||
>
|
||||
{dataset.mountpoint ||
|
||||
(dataset.type === 'volume' ? 'zvol' : '—')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={linkedDisks().length > 0}>
|
||||
<div class={STORAGE_DETAIL_CARD_CLASS}>
|
||||
<h4 class={STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS}>
|
||||
Physical Disks ({linkedDisks().length})
|
||||
</h4>
|
||||
<div class={STORAGE_DETAIL_LINKED_DISKS_LIST_CLASS}>
|
||||
<For each={linkedDisks()}>
|
||||
{(disk) => (
|
||||
<div class={STORAGE_DETAIL_LINKED_DISK_ROW_CLASS}>
|
||||
<span class={STORAGE_DETAIL_LINKED_DISK_PATH_CLASS} title={disk.devPath}>
|
||||
{disk.devPath}
|
||||
</span>
|
||||
<StatusDot
|
||||
variant={getLinkedDiskHealthDotVariant(disk.hasIssue)}
|
||||
size="sm"
|
||||
ariaHidden
|
||||
/>
|
||||
<span class={STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS}>{disk.model}</span>
|
||||
<Show when={disk.role}>
|
||||
<span class="shrink-0 rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px] uppercase text-muted">
|
||||
{disk.role}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={disk.sizeLabel}>
|
||||
<span class="shrink-0 font-mono text-muted">{disk.sizeLabel}</span>
|
||||
</Show>
|
||||
<Show when={disk.state}>
|
||||
<span class="shrink-0 text-muted">{disk.state}</span>
|
||||
</Show>
|
||||
<Show when={disk.ioLabel}>
|
||||
<span class="shrink-0 font-mono text-muted">{disk.ioLabel}</span>
|
||||
</Show>
|
||||
<Show when={disk.spunDown}>
|
||||
<span class="shrink-0 text-muted">spun down</span>
|
||||
</Show>
|
||||
<Show when={disk.errorCount > 0}>
|
||||
<span class="shrink-0 font-semibold text-amber-700 dark:text-amber-300">
|
||||
{disk.errorCount.toLocaleString()} errors
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={disk.temperature > 0}>
|
||||
<span
|
||||
class={`font-medium ${
|
||||
disk.temperatureLastKnownTitle
|
||||
? PHYSICAL_DISK_TEMPERATURE_LAST_KNOWN_CLASS
|
||||
: getLinkedDiskTemperatureTextClass(
|
||||
disk.temperature,
|
||||
getDiskTemperatureThresholds(
|
||||
disk.diskType,
|
||||
disk.alertResourceIds,
|
||||
),
|
||||
)
|
||||
}`}
|
||||
title={disk.temperatureLastKnownTitle}
|
||||
>
|
||||
{formatTemperature(disk.temperature)}
|
||||
<Show when={disk.temperatureLastKnownTitle}>
|
||||
<span class="sr-only">, last known</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={facts().length > 0}>
|
||||
<div
|
||||
data-testid="storage-pool-drawer-facts"
|
||||
class="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"
|
||||
>
|
||||
<For each={facts()}>
|
||||
{(fact) => <span class="max-w-full whitespace-normal wrap-anywhere">{fact}</span>}
|
||||
</For>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
|
||||
<Show when={deviceSections().length > 0}>
|
||||
<TechnicalDetailsSection dataTestId="storage-pool-devices" sections={deviceSections()} />
|
||||
</Show>
|
||||
|
||||
<Show when={detailSections().length > 0}>
|
||||
<TechnicalDetailsDisclosure
|
||||
dataTestId="storage-pool-technical-details"
|
||||
title="Details"
|
||||
keepMounted
|
||||
subtitle={detailSubtitle()}
|
||||
class="border-t border-border pt-2"
|
||||
contentClass="mt-2"
|
||||
sections={detailSections()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, Show, createMemo, createSignal } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { ResourceOperatorStateSection } from '@/components/Infrastructure/ResourceOperatorStateSection';
|
||||
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
|
||||
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
|
||||
import { Subtabs } from '@/components/shared/Subtabs';
|
||||
import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE } from '@/components/Workloads/guestDrawerModel';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import { getStoragePoolStateIndicator } from '@/features/storageBackups/rowPresentation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { StoragePoolDetail } from './StoragePoolDetail';
|
||||
|
||||
interface StoragePoolDrawerProps {
|
||||
record: StorageRecord;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
/** The pool's own open alerts, matched the way its row matches them. */
|
||||
alerts?: Alert[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type StoragePoolDrawerTab = 'overview' | 'manage';
|
||||
|
||||
const StoragePoolDrawerContent: Component<StoragePoolDrawerProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal<StoragePoolDrawerTab>('overview');
|
||||
const [historyRange, setHistoryRange] = createSignal<HistoryTimeRange>(
|
||||
GUEST_DRAWER_HISTORY_DEFAULT_RANGE,
|
||||
);
|
||||
const headingId = () => `storage-pool-drawer-heading-${props.record.id}`;
|
||||
// The same judgement that colours the row's State cell.
|
||||
const headerIndicator = createMemo(() => getStoragePoolStateIndicator(props.record));
|
||||
|
||||
return (
|
||||
<section class="space-y-3" aria-labelledby={headingId()} data-testid="storage-pool-drawer">
|
||||
<ObjectDrawerHeader
|
||||
collapseLabel={`Collapse ${props.record.name} details`}
|
||||
onCollapse={props.onClose}
|
||||
>
|
||||
<DrawerSubjectHeading
|
||||
headingId={headingId()}
|
||||
title={props.record.name}
|
||||
statusVariant={headerIndicator().variant}
|
||||
statusLabel={headerIndicator().label}
|
||||
/>
|
||||
</ObjectDrawerHeader>
|
||||
|
||||
<Subtabs
|
||||
class="mb-1"
|
||||
ariaLabel="Storage pool drawer sections"
|
||||
value={activeTab()}
|
||||
onChange={(value) => setActiveTab(value as StoragePoolDrawerTab)}
|
||||
tabs={[
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'manage', label: 'Manage' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Use CSS hidden instead of Show to avoid mount/unmount which causes scroll jumps.
|
||||
overflow-anchor: none prevents browser scroll anchoring from jumping when display toggles. */}
|
||||
<div class={`[overflow-anchor:none] ${activeTab() === 'overview' ? '' : 'hidden'}`}>
|
||||
<StoragePoolDetail
|
||||
record={props.record}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
alerts={props.alerts}
|
||||
historyRange={historyRange()}
|
||||
onHistoryRangeChange={setHistoryRange}
|
||||
historyEnabled={activeTab() === 'overview'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class={`[overflow-anchor:none] ${activeTab() === 'manage' ? '' : 'hidden'}`}>
|
||||
<Show when={activeTab() === 'manage'}>
|
||||
<div data-testid="storage-pool-manage-tab">
|
||||
{/* A pool row is one unified storage resource; its operator state
|
||||
is keyed on that resource like every other drawer's. */}
|
||||
<ResourceOperatorStateSection
|
||||
resourceId={props.record.id}
|
||||
resourceType="storage"
|
||||
platformType={props.record.source.platform}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// Another pool must not inherit this pool's tab, range or operator form.
|
||||
export const StoragePoolDrawer: Component<StoragePoolDrawerProps> = (props) => (
|
||||
<Show when={props.record.id} keyed>
|
||||
{(_recordId) => <StoragePoolDrawerContent {...props} />}
|
||||
</Show>
|
||||
);
|
||||
@@ -28,19 +28,22 @@ import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBack
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getStoragePoolCellPaddingClass,
|
||||
getStoragePoolTableColumns,
|
||||
isStoragePoolColumnVisible,
|
||||
type StoragePoolTableColumnId,
|
||||
type StoragePoolTableLayoutMode,
|
||||
} from '@/features/storageBackups/storagePagePresentation';
|
||||
import { getPlatformTableCellClassForKind } from '@/features/platformPage/sharedPlatformPage';
|
||||
import { EnhancedStorageBar } from './EnhancedStorageBar';
|
||||
import { StoragePoolDetail } from './StoragePoolDetail';
|
||||
import { StoragePoolDrawer } from './StoragePoolDrawer';
|
||||
import {
|
||||
buildSummaryDisclosureControlsId,
|
||||
createSummaryInteractiveRowPreviewHandlers,
|
||||
} from '@/components/shared/summaryInteractionA11y';
|
||||
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { createRowTextSelectionGuard } from '@/components/shared/Table';
|
||||
import type { Alert } from '@/types/api';
|
||||
|
||||
interface StoragePoolRowProps {
|
||||
layoutMode: StoragePoolTableLayoutMode;
|
||||
@@ -57,6 +60,8 @@ interface StoragePoolRowProps {
|
||||
alertHeadlineClass?: string;
|
||||
physicalDisks: Resource[];
|
||||
getDiskAlertResourceIds?: PhysicalDiskAlertResourceIdResolver;
|
||||
/** The pool's open alerts, for its drawer; the same set its row is tinted by. */
|
||||
alerts?: Alert[];
|
||||
alertDataAttrs: {
|
||||
'data-row-id': string;
|
||||
'data-alert-state': string;
|
||||
@@ -239,13 +244,19 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
|
||||
</td>
|
||||
</tr>
|
||||
<Show when={props.expanded}>
|
||||
<StoragePoolDetail
|
||||
record={props.record}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
summarySeriesId={props.summarySeriesId}
|
||||
controlsId={detailControlsId()}
|
||||
/>
|
||||
<InlineDetailTableRow
|
||||
cellId={detailControlsId()}
|
||||
colspan={getStoragePoolTableColumns('').length}
|
||||
data-inline-detail-for={props.summarySeriesId}
|
||||
>
|
||||
<StoragePoolDrawer
|
||||
record={props.record}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
alerts={props.alerts}
|
||||
onClose={props.onToggleExpand}
|
||||
/>
|
||||
</InlineDetailTableRow>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { resolveStorageRecordMetricResourceId } from '@/features/storageBackups/
|
||||
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
|
||||
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { StorageGroupRow } from './StorageGroupRow';
|
||||
import { StoragePoolRow } from './StoragePoolRow';
|
||||
@@ -48,6 +49,8 @@ type StoragePoolsTableProps = {
|
||||
nodeOnlineByLabel: Map<string, boolean>;
|
||||
highlightedRecordId: string | null;
|
||||
getRecordAlertState: (recordId: string) => StorageAlertRowState;
|
||||
/** A pool's open alerts, for its drawer: the set that tints its row. */
|
||||
getRecordAlerts?: (recordId: string) => Alert[];
|
||||
isLoading: boolean;
|
||||
highlightedSummarySeriesId?: string | null;
|
||||
onHoverChange?: (recordId: string | null) => void;
|
||||
@@ -176,6 +179,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
|
||||
alertHeadlineClass={rowModel().alertHeadlineClass}
|
||||
physicalDisks={props.physicalDisks}
|
||||
getDiskAlertResourceIds={props.getDiskAlertResourceIds}
|
||||
alerts={props.getRecordAlerts?.(record.id)}
|
||||
alertDataAttrs={rowModel().alertDataAttrs}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DiskDrawer } from '@/components/Storage/DiskDrawer';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const historySpy = vi.fn();
|
||||
|
||||
// The drawer charts with the shared drawer history component; its own suite
|
||||
// covers the charts. This stub records what the disk asks it to draw.
|
||||
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
||||
GuestDrawerHistory: (props: {
|
||||
target: { resourceType: string; resourceId: string } | null;
|
||||
range: string;
|
||||
enabled?: boolean;
|
||||
groups?: Array<{ id: string; series: Array<{ metric: string }> }>;
|
||||
}) => {
|
||||
historySpy(props);
|
||||
return (
|
||||
<div
|
||||
data-testid="drawer-history"
|
||||
data-enabled={String(props.enabled !== false)}
|
||||
data-range={props.range}
|
||||
data-metrics={props.groups
|
||||
?.flatMap((group) => group.series.map((series) => series.metric))
|
||||
.join(',')}
|
||||
>
|
||||
{props.target?.resourceType}:{props.target?.resourceId}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Infrastructure/ResourceOperatorStateSection', () => ({
|
||||
ResourceOperatorStateSection: (props: { resourceId: string; resourceType?: string }) => (
|
||||
<div data-testid="operator-state">
|
||||
{props.resourceType}:{props.resourceId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const buildDisk = (overrides: Partial<NonNullable<Resource['physicalDisk']>> = {}): Resource =>
|
||||
({
|
||||
id: 'disk-1',
|
||||
type: 'physical_disk',
|
||||
name: 'disk-1',
|
||||
displayName: 'Archive HDD',
|
||||
platformId: 'cluster-main',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
metricsTarget: { resourceType: 'disk', resourceId: 'agent-tower:sda' },
|
||||
identity: { hostname: 'tower' },
|
||||
canonicalIdentity: { hostname: 'tower' },
|
||||
platformData: {
|
||||
proxmox: { nodeName: 'tower', instance: 'cluster-main' },
|
||||
},
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'SERIAL-WITH-A-VERY-LONG-IDENTIFIER-0123456789',
|
||||
diskType: 'hdd',
|
||||
health: 'PASSED',
|
||||
temperature: 42,
|
||||
sizeBytes: 4_000_000_000_000,
|
||||
smart: { powerOnHours: 100, reallocatedSectors: 0, pendingSectors: 0 },
|
||||
...overrides,
|
||||
},
|
||||
}) as unknown as Resource;
|
||||
|
||||
const alert = (overrides: Partial<Alert> = {}): Alert => ({
|
||||
id: 'alert-1',
|
||||
type: 'disk-health',
|
||||
level: 'critical',
|
||||
resourceId: 'cluster-main:tower:disk:dev-sda',
|
||||
resourceName: 'Archive HDD (/dev/sda)',
|
||||
node: 'tower',
|
||||
instance: 'cluster-main',
|
||||
message: 'Disk health check failed',
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
startTime: '2026-10-20T00:00:00Z',
|
||||
acknowledged: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderDrawer = (
|
||||
disk: () => Resource,
|
||||
props: { alerts?: Alert[]; onClose?: () => void } = {},
|
||||
) =>
|
||||
render(() => (
|
||||
<DiskDrawer disk={disk()} alerts={props.alerts} onClose={props.onClose ?? (() => {})} />
|
||||
));
|
||||
|
||||
describe('DiskDrawer', () => {
|
||||
it('heads the drawer with the disk model and the Health cell verdict, and collapses', () => {
|
||||
const onClose = vi.fn();
|
||||
renderDrawer(() => buildDisk({ health: 'FAILED' }), { onClose });
|
||||
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Archive HDD' })).toBeInTheDocument();
|
||||
const dot = screen.getByLabelText('Replace Now');
|
||||
expect(dot.className).toContain('red');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse Archive HDD details' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says there are no active alerts, or lists the disk alerts first', () => {
|
||||
const [alerts, setAlerts] = createSignal<Alert[]>([]);
|
||||
render(() => <DiskDrawer disk={buildDisk()} alerts={alerts()} onClose={() => {}} />);
|
||||
expect(screen.getByTestId('drawer-no-attention')).toHaveTextContent('No active alerts');
|
||||
|
||||
setAlerts([alert()]);
|
||||
expect(screen.queryByTestId('drawer-no-attention')).toBeNull();
|
||||
expect(screen.getByTestId('drawer-attention-section')).toHaveTextContent(
|
||||
'Disk health check failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('lifts the health verdict and out-of-range SMART attributes into Worth checking', () => {
|
||||
renderDrawer(() =>
|
||||
buildDisk({
|
||||
health: 'FAILED',
|
||||
smart: { powerOnHours: 100, reallocatedSectors: 8, pendingSectors: 2 },
|
||||
}),
|
||||
);
|
||||
|
||||
const worth = screen.getByTestId('disk-drawer-exceptions');
|
||||
expect(worth).toHaveTextContent('Replace Now: Disk health has degraded to a critical state.');
|
||||
expect(within(worth).getByText('Reallocated Sectors')).toBeInTheDocument();
|
||||
expect(within(worth).getByText('Pending Sectors')).toBeInTheDocument();
|
||||
// Lifted facts move rather than repeat in Details.
|
||||
const details = screen.getByTestId('disk-technical-details');
|
||||
expect(within(details).queryByText('Reallocated Sectors')).toBeNull();
|
||||
expect(within(details).getByText('Power-On Time')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not restate a health verdict that one of the disk alerts already states', () => {
|
||||
renderDrawer(() => buildDisk({ health: 'FAILED' }), { alerts: [alert()] });
|
||||
|
||||
expect(screen.getByTestId('drawer-attention-section')).toHaveTextContent(
|
||||
'Disk health check failed',
|
||||
);
|
||||
expect(screen.queryByTestId('disk-drawer-exceptions')).toBeNull();
|
||||
// The verdict stays in the SMART health card instead.
|
||||
expect(screen.getByTestId('disk-drawer-health')).toHaveTextContent('Replace Now');
|
||||
});
|
||||
|
||||
it('shows no Worth checking for a healthy disk and keeps the health card in front', () => {
|
||||
renderDrawer(() => buildDisk());
|
||||
expect(screen.queryByTestId('disk-drawer-exceptions')).toBeNull();
|
||||
const card = screen.getByTestId('disk-drawer-health-card');
|
||||
expect(within(card).getByText('Healthy')).toBeInTheDocument();
|
||||
expect(within(card).getByText('42°C')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('charts temperature, I/O and wear for the disk at the drawer default range', async () => {
|
||||
historySpy.mockClear();
|
||||
renderDrawer(() => buildDisk());
|
||||
const chart = await screen.findByTestId('drawer-history');
|
||||
expect(chart).toHaveTextContent('disk:agent-tower:sda');
|
||||
expect(chart).toHaveAttribute('data-range', '24h');
|
||||
expect(chart).toHaveAttribute(
|
||||
'data-metrics',
|
||||
'smart_temp,diskread,diskwrite,disk,smart_reallocated_sectors',
|
||||
);
|
||||
expect(historySpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
currentMetrics: expect.objectContaining({ smart_temp: 42, smart_reallocated_sectors: 0 }),
|
||||
}),
|
||||
);
|
||||
// No section of its own called History, and no Live I/O section.
|
||||
expect(screen.queryByText('History')).toBeNull();
|
||||
expect(screen.queryByText('Live I/O (30m)')).toBeNull();
|
||||
expect(screen.queryByRole('combobox', { name: 'Disk history range' })).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves I/O panels out, and says why, where the collector cannot read per-disk I/O', async () => {
|
||||
renderDrawer(() =>
|
||||
buildDisk({
|
||||
diskType: 'nvme',
|
||||
smart: { percentageUsed: 12, availableSpare: 97 },
|
||||
collection: {
|
||||
temperature: {
|
||||
state: 'unavailable',
|
||||
source: 'smartctl',
|
||||
reason: 'collection deadline exceeded',
|
||||
},
|
||||
io: {
|
||||
state: 'unsupported',
|
||||
source: 'controller',
|
||||
reason: 'per-member counters unavailable',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByTestId('drawer-history')).toHaveAttribute(
|
||||
'data-metrics',
|
||||
'smart_temp,smart_percentage_used,smart_available_spare',
|
||||
);
|
||||
const notices = screen.getByTestId('disk-drawer-notices');
|
||||
expect(notices).toHaveTextContent(
|
||||
'Temperature is temporarily unavailable: collection deadline exceeded',
|
||||
);
|
||||
expect(notices).toHaveTextContent('Disk I/O is unsupported: per-member counters unavailable');
|
||||
// A retained reading is labelled last known, with no health tone.
|
||||
const card = screen.getByTestId('disk-drawer-health-card');
|
||||
expect(within(card).getByText('Last known temperature')).toBeInTheDocument();
|
||||
expect(within(card).getByText('42°C').closest('td')).toHaveClass('text-muted');
|
||||
expect(historySpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
deferredMetrics: { smart_temp: expect.objectContaining({ lastKnownValue: 42 }) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('states plainly when a disk reports no SMART detail at all', () => {
|
||||
renderDrawer(() => buildDisk({ smart: undefined, temperature: undefined }));
|
||||
expect(screen.getByTestId('disk-drawer-notices')).toHaveTextContent(
|
||||
'Detailed SMART attributes are not available for this disk.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps identity in a closed Details disclosure, wrapping the values operators copy', () => {
|
||||
renderDrawer(() => buildDisk());
|
||||
const details = screen.getByTestId('disk-technical-details') as HTMLDetailsElement;
|
||||
expect(details.tagName).toBe('DETAILS');
|
||||
expect(details.open).toBe(false);
|
||||
const serial = within(details).getByText('SERIAL-WITH-A-VERY-LONG-IDENTIFIER-0123456789');
|
||||
expect(serial).toHaveClass('whitespace-normal');
|
||||
expect(serial).not.toHaveClass('truncate');
|
||||
expect(within(details).getByText('/dev/sda')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('disk-drawer-facts')).toHaveTextContent('HDD · 3.64 TB');
|
||||
});
|
||||
|
||||
it('pauses the charts while Manage shows the disk operator state', async () => {
|
||||
renderDrawer(() => buildDisk());
|
||||
expect(await screen.findByTestId('drawer-history')).toHaveAttribute('data-enabled', 'true');
|
||||
expect(screen.queryByRole('tab', { name: 'History' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
|
||||
expect(screen.getByTestId('operator-state')).toHaveTextContent('physical_disk:disk-1');
|
||||
expect(screen.getByTestId('drawer-history')).toHaveAttribute('data-enabled', 'false');
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,15 @@ import { DiskList } from '@/components/Storage/DiskList';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import { eventBus } from '@/stores/events';
|
||||
|
||||
vi.mock('@/components/Storage/DiskDetail', () => ({
|
||||
DiskDetail: (props: { disk: Resource }) => <div data-testid="disk-detail">{props.disk.id}</div>,
|
||||
vi.mock('@/components/Storage/DiskDrawer', () => ({
|
||||
DiskDrawer: (props: { disk: Resource; onClose: () => void }) => (
|
||||
<div data-testid="disk-detail">
|
||||
{props.disk.id}
|
||||
<button type="button" onClick={() => props.onClose()}>
|
||||
Close drawer
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const buildNode = (id: string, name: string): Resource =>
|
||||
@@ -554,6 +561,38 @@ describe('DiskList', () => {
|
||||
expect(screen.getByTestId('disk-detail')).toHaveTextContent('sdb');
|
||||
});
|
||||
|
||||
it('opens the disk drawer in the shared inline detail row its disclosure controls', () => {
|
||||
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>(null);
|
||||
const onSelectedDiskChange = vi.fn((id: string | null) => setSelectedDiskId(id));
|
||||
render(() => (
|
||||
<DiskList
|
||||
disks={[buildDisk('sda', 'tower')]}
|
||||
nodes={[]}
|
||||
selectedNode={null}
|
||||
searchTerm=""
|
||||
selectedDiskId={selectedDiskId()}
|
||||
onSelectedDiskChange={onSelectedDiskChange}
|
||||
/>
|
||||
));
|
||||
|
||||
const row = document.querySelector('[data-row-id="sda"]') as HTMLElement;
|
||||
const disclosure = within(row).getByRole('button');
|
||||
fireEvent.click(disclosure);
|
||||
const drawer = screen.getByTestId('disk-detail');
|
||||
const cell = drawer.closest('td')!;
|
||||
expect(cell.id).toBe(disclosure.getAttribute('aria-controls'));
|
||||
expect(cell.closest('tr')).toHaveAttribute(
|
||||
'data-inline-detail-for',
|
||||
row.getAttribute('data-summary-series-id'),
|
||||
);
|
||||
// The shared detail row caps its content to the table width on phones.
|
||||
expect(drawer.parentElement).toHaveClass('max-w-[100cqi]');
|
||||
|
||||
fireEvent.click(within(drawer).getByRole('button', { name: 'Close drawer' }));
|
||||
expect(onSelectedDiskChange).toHaveBeenLastCalledWith(null);
|
||||
expect(screen.queryByTestId('disk-detail')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders canonical Settings Infrastructure guidance for empty physical disks', () => {
|
||||
renderDiskList({
|
||||
disks: [],
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { Alert } from '@/types/api';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
import Storage from '@/components/Storage/Storage';
|
||||
import { ROUTE_STATE_REPLACE_OPTIONS } from '@/utils/routeStateNavigation';
|
||||
// Pool and disk drawers load their charts lazily; warm the module so a cold
|
||||
// transform does not outlast the tests' waits.
|
||||
import '@/components/Workloads/GuestDrawerHistory';
|
||||
|
||||
// Stub ResizeObserver for jsdom (used by HistoryChart in pool detail panels)
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
@@ -943,7 +946,10 @@ describe('Storage', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Collapse pve1' })).toBeInTheDocument();
|
||||
const poolRow = screen.getByText('Ceph-Link-Store').closest('tr[data-summary-series-id]');
|
||||
// The open pool's drawer names it too; the row's name comes first.
|
||||
const poolRow = screen
|
||||
.getAllByText('Ceph-Link-Store')[0]
|
||||
.closest('tr[data-summary-series-id]');
|
||||
expect(
|
||||
document
|
||||
.querySelector('tr[data-inline-detail-for]')
|
||||
@@ -1079,11 +1085,10 @@ describe('Storage', () => {
|
||||
const metricsHistorySpy = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pool:tank',
|
||||
metric: 'usage',
|
||||
range: '7d',
|
||||
start: Date.now() - 7 * 24 * 60 * 60 * 1000,
|
||||
range: '24h',
|
||||
start: Date.now() - 24 * 60 * 60 * 1000,
|
||||
end: Date.now(),
|
||||
points: [],
|
||||
metrics: { usage: [] },
|
||||
source: 'store',
|
||||
});
|
||||
|
||||
@@ -1111,12 +1116,13 @@ describe('Storage', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand tank' }));
|
||||
|
||||
// The pool drawer charts its capacity at the drawer's 24h default.
|
||||
await waitFor(() => {
|
||||
expect(metricsHistorySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pool:tank',
|
||||
metric: 'usage',
|
||||
range: '24h',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+12
-13
@@ -6,8 +6,8 @@ import { buildStorageRecords } from '@/features/storageBackups/storageAdapters';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { State } from '@/types/api';
|
||||
|
||||
vi.mock('@/components/shared/HistoryChart', () => ({
|
||||
HistoryChart: () => <div>History fixture</div>,
|
||||
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
||||
GuestDrawerHistory: () => <div>History fixture</div>,
|
||||
}));
|
||||
|
||||
const pool = (owner: string | undefined): Resource =>
|
||||
@@ -51,15 +51,9 @@ describe('Storage pool ownership through live snapshots', () => {
|
||||
disk('b', 'host-b', 'Peer disk', 99),
|
||||
]);
|
||||
const record = () => buildStorageRecords({ state: {} as State, resources: [resource()] })[0];
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail record={record()} physicalDisks={disks()} summarySeriesId="tank" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
render(() => <StoragePoolDetail record={record()} physicalDisks={disks()} />);
|
||||
|
||||
expect(screen.getByText('Physical Disks (1)')).toBeVisible();
|
||||
expect(screen.getByText('Physical disks (1)')).toBeVisible();
|
||||
expect(screen.getByText('Local disk')).toBeVisible();
|
||||
expect(screen.queryByText('Peer disk')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('99 errors')).not.toBeInTheDocument();
|
||||
@@ -68,18 +62,23 @@ describe('Storage pool ownership through live snapshots', () => {
|
||||
expect(screen.queryByText('Local disk')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Peer disk')).toBeVisible();
|
||||
expect(screen.getByText('99 errors')).toBeVisible();
|
||||
// The peer disk's errors and heat are now this pool's to check.
|
||||
expect(screen.getByTestId('storage-pool-drawer-exceptions')).toHaveTextContent(
|
||||
'/dev/sda: 99 errors',
|
||||
);
|
||||
expect(screen.getByText('82°C')).toBeVisible();
|
||||
expect(screen.getByText('Peer disk')).toBeVisible();
|
||||
|
||||
setResource(pool(undefined));
|
||||
expect(screen.queryByText(/Physical Disks/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('ZFS Pool')).toBeVisible();
|
||||
expect(screen.queryByText(/Physical disks/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('ZFS pool')).toBeVisible();
|
||||
expect(screen.queryByTestId('storage-pool-drawer-exceptions')).toBeNull();
|
||||
setResource(pool('host-a'));
|
||||
setDisks([disk('a', 'host-a', 'Local disk', 4)]);
|
||||
expect(screen.getByText('Local disk')).toBeVisible();
|
||||
expect(screen.getByText('4 errors')).toBeVisible();
|
||||
setDisks([]);
|
||||
expect(screen.queryByText(/Physical Disks/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Physical disks/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('4 errors')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import { fireEvent, render, screen, within } from '@solidjs/testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { StoragePoolDetail } from '@/components/Storage/StoragePoolDetail';
|
||||
import { StoragePoolDrawer } from '@/components/Storage/StoragePoolDrawer';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const historyChartSpy = vi.fn();
|
||||
const historySpy = vi.fn();
|
||||
|
||||
vi.mock('@/components/shared/HistoryChart', () => ({
|
||||
HistoryChart: (props: {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
metric: string;
|
||||
range?: string;
|
||||
// The drawer charts with the shared drawer history component; its own suite
|
||||
// covers the charts. This stub records what the pool asks it to draw.
|
||||
vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
|
||||
GuestDrawerHistory: (props: {
|
||||
target: { resourceType: string; resourceId: string } | null;
|
||||
range: string;
|
||||
enabled?: boolean;
|
||||
groups?: Array<{ id: string; series: Array<{ metric: string }> }>;
|
||||
currentMetrics?: Record<string, number | undefined>;
|
||||
alerts?: Record<string, { severity: string; value?: number } | undefined>;
|
||||
onRangeChange?: (range: string) => void;
|
||||
}) => {
|
||||
historyChartSpy(props);
|
||||
historySpy(props);
|
||||
return (
|
||||
<div data-testid="history-chart">
|
||||
{props.resourceType}:{props.resourceId}:{props.metric}:{props.range}
|
||||
<div
|
||||
data-testid="drawer-history"
|
||||
data-enabled={String(props.enabled !== false)}
|
||||
data-range={props.range}
|
||||
>
|
||||
{props.target?.resourceType}:{props.target?.resourceId}:
|
||||
{props.groups?.flatMap((group) => group.series.map((series) => series.metric)).join(',')}
|
||||
<button type="button" onClick={() => props.onRangeChange?.('7d')}>
|
||||
Pick 7 days
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/Infrastructure/ResourceOperatorStateSection', () => ({
|
||||
ResourceOperatorStateSection: (props: { resourceId: string; resourceType?: string }) => (
|
||||
<div data-testid="operator-state">
|
||||
{props.resourceType}:{props.resourceId}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const makeRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
@@ -41,299 +64,347 @@ const makeRecord = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('StoragePoolDetail', () => {
|
||||
it('updates capacity rows without presenting missing readings as an empty pool', () => {
|
||||
const zfsPool = (overrides: Record<string, unknown> = {}) => ({
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
status: 'Healthy',
|
||||
scan: 'none',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [],
|
||||
datasets: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const alert = (overrides: Partial<Alert> = {}): Alert => ({
|
||||
id: 'alert-1',
|
||||
type: 'usage',
|
||||
level: 'warning',
|
||||
resourceId: 'storage-1',
|
||||
resourceName: 'tank',
|
||||
node: 'truenas01',
|
||||
instance: 'truenas',
|
||||
message: 'Storage usage is 91%',
|
||||
value: 91,
|
||||
threshold: 85,
|
||||
startTime: '2026-10-20T00:00:00Z',
|
||||
acknowledged: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderOverview = (record: () => StorageRecord, props: Record<string, unknown> = {}) =>
|
||||
render(() => <StoragePoolDetail record={record()} physicalDisks={[]} {...props} />);
|
||||
|
||||
// A Details row's value, read from the shared detail table.
|
||||
const detailValue = (label: string) =>
|
||||
within(screen.getByTestId('storage-pool-technical-details'))
|
||||
.getByText(label, { exact: true })
|
||||
.closest('tr')
|
||||
?.lastElementChild?.textContent?.trim();
|
||||
|
||||
describe('StoragePoolDetail (pool drawer Overview)', () => {
|
||||
it('keeps capacity rows in Details without presenting missing readings as an empty pool', () => {
|
||||
const [record, setRecord] = createSignal(makeRecord());
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail record={record()} physicalDisks={[]} summarySeriesId="pool:tank" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
const rowValue = (label: string) =>
|
||||
screen.getByText(label, { exact: true }).parentElement?.lastElementChild?.textContent;
|
||||
expect(rowValue('Usage')).toBe('40%');
|
||||
renderOverview(record);
|
||||
|
||||
const details = screen.getByTestId('storage-pool-technical-details') as HTMLDetailsElement;
|
||||
expect(details.tagName).toBe('DETAILS');
|
||||
expect(details.open).toBe(false);
|
||||
expect(detailValue('Usage')).toBe('40%');
|
||||
|
||||
setRecord(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: 1000, usedBytes: null, freeBytes: null, usagePercent: null },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('n/a');
|
||||
expect(rowValue('Free')).toBe('n/a');
|
||||
expect(rowValue('Total')).toBe('1000 B');
|
||||
expect(rowValue('Usage')).toBe('n/a');
|
||||
expect(detailValue('Used')).toBe('n/a');
|
||||
expect(detailValue('Free')).toBe('n/a');
|
||||
expect(detailValue('Total')).toBe('1000 B');
|
||||
expect(detailValue('Usage')).toBe('n/a');
|
||||
|
||||
setRecord(
|
||||
makeRecord({
|
||||
capacity: { totalBytes: 1000, usedBytes: 0, freeBytes: null, usagePercent: null },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('0 B');
|
||||
expect(rowValue('Free')).toBe('1000 B');
|
||||
expect(rowValue('Usage')).toBe('0%');
|
||||
setRecord(
|
||||
expect(detailValue('Used')).toBe('0 B');
|
||||
expect(detailValue('Free')).toBe('1000 B');
|
||||
expect(detailValue('Usage')).toBe('0%');
|
||||
});
|
||||
|
||||
it('charts the canonical metrics target at the drawer default range', async () => {
|
||||
historySpy.mockClear();
|
||||
renderOverview(() =>
|
||||
makeRecord({
|
||||
capacity: { totalBytes: null, usedBytes: 500, freeBytes: 0, usagePercent: null },
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
|
||||
refs: { resourceId: 'storage-legacy-id', platformEntityId: 'truenas-1' },
|
||||
}),
|
||||
);
|
||||
expect(rowValue('Used')).toBe('500 B');
|
||||
expect(rowValue('Free')).toBe('0 B');
|
||||
expect(rowValue('Total')).toBe('n/a');
|
||||
expect(rowValue('Usage')).toBe('n/a');
|
||||
|
||||
const chart = await screen.findByTestId('drawer-history');
|
||||
expect(chart).toHaveTextContent('storage:pool:tank:usage');
|
||||
expect(chart).toHaveAttribute('data-range', '24h');
|
||||
expect(historySpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ currentMetrics: { usage: 40 } }),
|
||||
);
|
||||
// No range dropdown of the pool's own: the picker is the charts' toolbar.
|
||||
expect(screen.queryByRole('combobox', { name: 'Capacity history range' })).toBeNull();
|
||||
});
|
||||
|
||||
it('uses canonical metrics target for capacity history charts', () => {
|
||||
historyChartSpy.mockClear();
|
||||
it('falls back to legacy storage refs, then the record id, for the chart target', async () => {
|
||||
const [record, setRecord] = createSignal(
|
||||
makeRecord({ refs: { resourceId: 'pve1:local-zfs', platformEntityId: 'cluster-a' } }),
|
||||
);
|
||||
renderOverview(record);
|
||||
expect(await screen.findByTestId('drawer-history')).toHaveTextContent(
|
||||
'storage:pve1:local-zfs:usage',
|
||||
);
|
||||
setRecord(makeRecord());
|
||||
expect(screen.getByTestId('drawer-history')).toHaveTextContent('storage:storage-1:usage');
|
||||
});
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
|
||||
refs: { resourceId: 'storage-legacy-id', platformEntityId: 'truenas-1' },
|
||||
})}
|
||||
physicalDisks={[]}
|
||||
summarySeriesId="pool:tank"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId('history-chart')).toHaveTextContent('storage:pool:tank:usage');
|
||||
expect(historyChartSpy).toHaveBeenCalledWith(
|
||||
it('withholds a stale pool usage as a live reading', async () => {
|
||||
historySpy.mockClear();
|
||||
renderOverview(() => makeRecord({ freshness: 'stale' }));
|
||||
await screen.findByTestId('drawer-history');
|
||||
expect(historySpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pool:tank',
|
||||
metric: 'usage',
|
||||
range: '7d',
|
||||
currentMetrics: {},
|
||||
deferredMetrics: {
|
||||
usage: expect.objectContaining({ lastKnownValue: 40 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The facts strip states the size only.
|
||||
expect(screen.getByTestId('storage-pool-drawer-facts')).toHaveTextContent('1000 B total');
|
||||
expect(screen.getByTestId('storage-pool-drawer-exceptions')).toHaveTextContent(
|
||||
'Retained last-known storage values.',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps pool detail range choices inside the current history entitlement', () => {
|
||||
historyChartSpy.mockClear();
|
||||
it('says there are no active alerts, or lists the pool alerts and draws their trigger', async () => {
|
||||
historySpy.mockClear();
|
||||
const [alerts, setAlerts] = createSignal<Alert[]>([]);
|
||||
render(() => <StoragePoolDetail record={makeRecord()} physicalDisks={[]} alerts={alerts()} />);
|
||||
expect(screen.getByTestId('drawer-no-attention')).toHaveTextContent('No active alerts');
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
|
||||
})}
|
||||
physicalDisks={[]}
|
||||
summarySeriesId="pool:tank"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
setAlerts([alert()]);
|
||||
expect(screen.queryByTestId('drawer-no-attention')).toBeNull();
|
||||
const attention = screen.getByTestId('drawer-attention-section');
|
||||
expect(attention).toHaveTextContent('Storage usage is 91%');
|
||||
await screen.findByTestId('drawer-history');
|
||||
expect(historySpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ alerts: { usage: { severity: 'warning', value: 85 } } }),
|
||||
);
|
||||
});
|
||||
|
||||
const rangeSelector = screen.getByRole('combobox', {
|
||||
name: 'Capacity history range',
|
||||
}) as HTMLSelectElement;
|
||||
expect(Array.from(rangeSelector.options).map((option) => option.value)).toEqual(['24h', '7d']);
|
||||
|
||||
fireEvent.change(rangeSelector, { target: { value: '7d' } });
|
||||
|
||||
expect(historyChartSpy).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pool:tank',
|
||||
metric: 'usage',
|
||||
range: '7d',
|
||||
it('lifts ZFS errors, failing devices, disk errors and hot disks into Worth checking', () => {
|
||||
renderOverview(() =>
|
||||
makeRecord({
|
||||
details: {
|
||||
type: 'zfspool',
|
||||
parentId: 'host-truenas01',
|
||||
zfsPool: zfsPool({
|
||||
state: 'DEGRADED',
|
||||
readErrors: 2,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 1,
|
||||
scan: 'scrub repaired 0B with 3 errors',
|
||||
scanDetails: { function: 'scrub', errors: 3 },
|
||||
devices: [
|
||||
{ name: 'sda', type: 'disk', state: 'ONLINE', readErrors: 0 },
|
||||
{
|
||||
name: 'ata-WDC_WD40EFRX-68N32N0_WD-WCC7K0000000',
|
||||
type: 'disk',
|
||||
state: 'FAULTED',
|
||||
readErrors: 3,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 1,
|
||||
message: 'too many errors',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const worth = screen.getByTestId('storage-pool-drawer-exceptions');
|
||||
expect(within(worth).getByText('Worth checking')).toBeInTheDocument();
|
||||
expect(worth).toHaveTextContent('Degraded');
|
||||
expect(worth).toHaveTextContent('Errors: R:2 W:0 C:1');
|
||||
expect(worth).toHaveTextContent('scrub repaired 0B with 3 errors');
|
||||
expect(worth).toHaveTextContent(
|
||||
'ata-WDC_WD40EFRX-68N32N0_WD-WCC7K0000000: Faulted, 3R/0W/1C errors, too many errors',
|
||||
);
|
||||
// A healthy member is not worth checking; it stays in the device list.
|
||||
expect(worth).not.toHaveTextContent('sda');
|
||||
const devices = screen.getByTestId('storage-pool-devices');
|
||||
expect(within(devices).getByText('sda')).toBeInTheDocument();
|
||||
// Lifted pool counters move rather than repeat.
|
||||
expect(devices).not.toHaveTextContent('Errors: R:2');
|
||||
});
|
||||
|
||||
it('falls back to legacy storage refs when metrics target is absent', () => {
|
||||
historyChartSpy.mockClear();
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
refs: { resourceId: 'pve1:local-zfs', platformEntityId: 'cluster-a' },
|
||||
})}
|
||||
physicalDisks={[] as Resource[]}
|
||||
summarySeriesId="pve1:local-zfs"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.getByTestId('history-chart')).toHaveTextContent('storage:pve1:local-zfs:usage');
|
||||
expect(historyChartSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pve1:local-zfs',
|
||||
metric: 'usage',
|
||||
it('wraps dataset names and mountpoints in the closed Details instead of truncating them', () => {
|
||||
renderOverview(() =>
|
||||
makeRecord({
|
||||
details: {
|
||||
type: 'zfspool',
|
||||
zfsPool: zfsPool({
|
||||
datasets: [
|
||||
{
|
||||
name: 'tank/apps/a-very-long-dataset-name-that-operators-copy',
|
||||
type: 'filesystem',
|
||||
mountpoint: '/mnt/tank/apps/a-very-long-dataset-name-that-operators-copy',
|
||||
usedBytes: 1_073_741_824,
|
||||
availableBytes: 2_147_483_648,
|
||||
referencedBytes: 536_870_912,
|
||||
},
|
||||
{ name: 'tank/vm-100-disk-0', type: 'volume', usedBytes: 1024 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const datasets = screen.getByTestId('storage-pool-datasets');
|
||||
const name = within(datasets).getByText(
|
||||
'tank/apps/a-very-long-dataset-name-that-operators-copy',
|
||||
);
|
||||
expect(name).toHaveClass('whitespace-normal');
|
||||
expect(name).not.toHaveClass('truncate');
|
||||
const value = within(datasets).getByText(/\/mnt\/tank\/apps\/a-very-long/);
|
||||
expect(value).toHaveClass('whitespace-normal');
|
||||
expect(value).not.toHaveClass('truncate');
|
||||
expect(value).toHaveTextContent('Used 1.00 GB · Available 2.00 GB · Referenced 512 MB');
|
||||
expect(within(datasets).getByText(/zvol/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Datasets (2)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the mobile split row and moves desktop values beside a fixed label track', () => {
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
location: { label: 'London Edge Standalone', scope: 'host' },
|
||||
})}
|
||||
physicalDisks={[]}
|
||||
summarySeriesId="pool:tank"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
const nodeLabel = screen.getByText('Node');
|
||||
expect(nodeLabel.parentElement).toHaveClass(
|
||||
'col-span-2',
|
||||
'sm:col-span-1',
|
||||
'min-w-0',
|
||||
'gap-3',
|
||||
'justify-between',
|
||||
'sm:grid',
|
||||
'sm:grid-cols-[7rem_minmax(0,1fr)]',
|
||||
it('renders UnRAID topology in Details and linked disk facts in front', () => {
|
||||
renderOverview(
|
||||
() =>
|
||||
makeRecord({
|
||||
name: 'Tower Array',
|
||||
source: {
|
||||
platform: 'unraid',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
details: {
|
||||
type: 'unraid-array',
|
||||
platform: 'unraid',
|
||||
topology: 'array',
|
||||
arrayState: 'STARTED',
|
||||
},
|
||||
}),
|
||||
{
|
||||
physicalDisks: [
|
||||
{
|
||||
id: 'disk1',
|
||||
parentId: 'storage-1',
|
||||
type: 'physical_disk',
|
||||
name: 'Disk 1',
|
||||
displayName: 'Disk 1',
|
||||
platformId: 'tower',
|
||||
platformType: 'unraid',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sdc',
|
||||
model: 'Data Disk',
|
||||
storageRole: 'data',
|
||||
storageGroup: 'unraid-array',
|
||||
storageState: 'online',
|
||||
sizeBytes: 6_000_000_000_000,
|
||||
temperature: 31,
|
||||
spunDown: true,
|
||||
readCount: 10,
|
||||
writeCount: 20,
|
||||
errorCount: 16,
|
||||
},
|
||||
},
|
||||
] as Resource[],
|
||||
},
|
||||
);
|
||||
expect(screen.getByText('London Edge Standalone')).toHaveClass(
|
||||
'min-w-0',
|
||||
'wrap-break-word',
|
||||
'text-right',
|
||||
'sm:text-left',
|
||||
|
||||
expect(detailValue('Kind')).toBe('Array');
|
||||
expect(detailValue('Data disks')).toBe('1 disk');
|
||||
const disks = screen.getByTestId('storage-pool-linked-disks');
|
||||
expect(within(disks).getByText('Physical disks (1)')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('/dev/sdc')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('Data Disk')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('data')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('R 10 / W 20')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('spun down')).toBeInTheDocument();
|
||||
expect(within(disks).getByText('16 errors')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('storage-pool-drawer-exceptions')).toHaveTextContent(
|
||||
'/dev/sdc: 16 errors',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders UnRAID topology and linked disk facts plainly', () => {
|
||||
historyChartSpy.mockClear();
|
||||
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
name: 'Tower Array',
|
||||
source: {
|
||||
platform: 'unraid',
|
||||
family: 'onprem',
|
||||
origin: 'resource',
|
||||
adapterId: 'resource-storage',
|
||||
},
|
||||
details: {
|
||||
type: 'unraid-array',
|
||||
platform: 'unraid',
|
||||
topology: 'array',
|
||||
arrayState: 'STARTED',
|
||||
},
|
||||
})}
|
||||
physicalDisks={
|
||||
[
|
||||
{
|
||||
id: 'disk1',
|
||||
parentId: 'storage-1',
|
||||
type: 'physical_disk',
|
||||
name: 'Disk 1',
|
||||
displayName: 'Disk 1',
|
||||
platformId: 'tower',
|
||||
platformType: 'unraid',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
physicalDisk: {
|
||||
devPath: '/dev/sdc',
|
||||
model: 'Data Disk',
|
||||
storageRole: 'data',
|
||||
storageGroup: 'unraid-array',
|
||||
storageState: 'online',
|
||||
sizeBytes: 6_000_000_000_000,
|
||||
temperature: 31,
|
||||
spunDown: true,
|
||||
readCount: 10,
|
||||
writeCount: 20,
|
||||
errorCount: 16,
|
||||
},
|
||||
},
|
||||
] as Resource[]
|
||||
}
|
||||
summarySeriesId="tower-array"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Topology')).toBeInTheDocument();
|
||||
expect(screen.getByText('Kind')).toBeInTheDocument();
|
||||
expect(screen.getByText('Array')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data disks')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('1 disk').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Data Disk')).toBeInTheDocument();
|
||||
expect(screen.getByText('data')).toBeInTheDocument();
|
||||
expect(screen.getByText('R 10 / W 20')).toBeInTheDocument();
|
||||
expect(screen.getByText('spun down')).toBeInTheDocument();
|
||||
expect(screen.getByText('16 errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the capacity trend and the pool details in one view, trend first', () => {
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail record={makeRecord()} physicalDisks={[]} summarySeriesId="tank" />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||
const trend = screen.getByTestId('storage-pool-capacity-trend');
|
||||
const configuration = screen.getByText('Configuration');
|
||||
expect(trend).toBeVisible();
|
||||
expect(configuration).toBeVisible();
|
||||
expect(
|
||||
trend.compareDocumentPosition(configuration) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders linked host-agent ZFS datasets inside the expanded pool detail', () => {
|
||||
render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<StoragePoolDetail
|
||||
record={makeRecord({
|
||||
details: {
|
||||
type: 'zfspool',
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
status: 'Healthy',
|
||||
scan: 'none',
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [],
|
||||
datasets: [
|
||||
{
|
||||
name: 'tank/apps',
|
||||
type: 'filesystem',
|
||||
mountpoint: '/tank/apps',
|
||||
usedBytes: 1_073_741_824,
|
||||
availableBytes: 2_147_483_648,
|
||||
referencedBytes: 536_870_912,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})}
|
||||
physicalDisks={[]}
|
||||
summarySeriesId="tank"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Datasets (1)')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank/apps')).toBeInTheDocument();
|
||||
expect(screen.getByText('1.00 GB')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.00 GB')).toBeInTheDocument();
|
||||
expect(screen.getByText('/tank/apps')).toBeInTheDocument();
|
||||
it('orders the Overview like the guest and node drawers', async () => {
|
||||
renderOverview(() => makeRecord());
|
||||
const overview = screen.getByTestId('storage-pool-drawer-overview');
|
||||
const order = [
|
||||
screen.getByTestId('drawer-no-attention'),
|
||||
await screen.findByTestId('drawer-history'),
|
||||
screen.getByTestId('storage-pool-drawer-facts'),
|
||||
screen.getByTestId('storage-pool-technical-details'),
|
||||
];
|
||||
for (let index = 1; index < order.length; index += 1) {
|
||||
expect(
|
||||
order[index - 1].compareDocumentPosition(order[index]) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(overview).toContainElement(order[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoragePoolDrawer', () => {
|
||||
it('heads the drawer with the pool name and the State cell judgement, and collapses', () => {
|
||||
const onClose = vi.fn();
|
||||
render(() => (
|
||||
<StoragePoolDrawer
|
||||
record={makeRecord({
|
||||
details: { type: 'zfspool', zfsPool: zfsPool({ state: 'DEGRADED' }) },
|
||||
})}
|
||||
physicalDisks={[]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
));
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2, name: 'tank' });
|
||||
expect(heading).toBeInTheDocument();
|
||||
const dot = screen.getByLabelText('Degraded');
|
||||
expect(dot.className).toContain('amber');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse tank details' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps Overview mounted and pauses its charts while Manage shows operator state', async () => {
|
||||
render(() => <StoragePoolDrawer record={makeRecord()} physicalDisks={[]} onClose={() => {}} />);
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.queryByRole('tab', { name: 'History' })).toBeNull();
|
||||
expect(await screen.findByTestId('drawer-history')).toHaveAttribute('data-enabled', 'true');
|
||||
expect(screen.queryByTestId('operator-state')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Manage' }));
|
||||
expect(screen.getByTestId('operator-state')).toHaveTextContent('storage:storage-1');
|
||||
expect(screen.getByTestId('drawer-history')).toHaveAttribute('data-enabled', 'false');
|
||||
expect(screen.getByTestId('storage-pool-drawer-overview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('owns the chart range in the drawer and resets it for another pool', async () => {
|
||||
const [record, setRecord] = createSignal(makeRecord());
|
||||
render(() => <StoragePoolDrawer record={record()} physicalDisks={[]} onClose={() => {}} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Pick 7 days' }));
|
||||
expect(screen.getByTestId('drawer-history')).toHaveAttribute('data-range', '7d');
|
||||
|
||||
setRecord(makeRecord({ id: 'storage-2', name: 'backup' }));
|
||||
expect(await screen.findByTestId('drawer-history')).toHaveAttribute('data-range', '24h');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getAgentMachineAlertOverrideKeys,
|
||||
getPhysicalDiskAlertResourceIds,
|
||||
getPhysicalDiskAlerts,
|
||||
indexPhysicalDiskOwners,
|
||||
} from '@/components/Storage/physicalDiskAlertOwner';
|
||||
|
||||
@@ -62,4 +64,54 @@ describe('physical disk alert owner', () => {
|
||||
expect(getPhysicalDiskAlertResourceIds(disk('vm-not-loaded'), owners)).toEqual([]);
|
||||
expect(getPhysicalDiskAlertResourceIds(disk(undefined), owners)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('the alerts a disk drawer lists', () => {
|
||||
const alertOn = (id: string, resourceId: string, level: Alert['level'] = 'warning'): Alert =>
|
||||
({
|
||||
id,
|
||||
type: 'disk-temperature',
|
||||
level,
|
||||
resourceId,
|
||||
resourceName: resourceId,
|
||||
node: 'tower',
|
||||
instance: 'lab',
|
||||
message: id,
|
||||
value: 0,
|
||||
threshold: 0,
|
||||
startTime: '2026-10-20T00:00:00Z',
|
||||
acknowledged: false,
|
||||
}) as Alert;
|
||||
const agentDisk = resource({
|
||||
id: 'disk-agent-sda',
|
||||
type: 'physical_disk',
|
||||
parentId: 'agent-tower',
|
||||
physicalDisk: { devPath: '/dev/sda' },
|
||||
canonicalIdentity: { aliases: ['lab:tower:disk:dev-sda', 'tower'] },
|
||||
});
|
||||
const active = {
|
||||
own: alertOn('own', 'disk-agent-sda'),
|
||||
pve: alertOn('pve', 'lab:tower:disk:dev-sda', 'critical'),
|
||||
agent: alertOn('agent', 'agent:host-tower/disk_temp:sda'),
|
||||
sibling: alertOn('sibling', 'agent:host-tower/disk_temp:sdb'),
|
||||
machine: alertOn('machine', 'agent:host-tower'),
|
||||
node: alertOn('node', 'tower'),
|
||||
};
|
||||
|
||||
it('lists its own, its Proxmox disk key and its agent temperature key, most severe first', () => {
|
||||
expect(
|
||||
getPhysicalDiskAlerts(agentDisk, owners, active, true).map((alert) => alert.id),
|
||||
).toEqual(['pve', 'own', 'agent']);
|
||||
});
|
||||
|
||||
it("never lists a sibling disk's, its machine's or its node's alerts", () => {
|
||||
const ids = getPhysicalDiskAlerts(agentDisk, owners, active, true).map((alert) => alert.id);
|
||||
expect(ids).not.toContain('sibling');
|
||||
expect(ids).not.toContain('machine');
|
||||
expect(ids).not.toContain('node');
|
||||
});
|
||||
|
||||
it('lists nothing while alert detection is off', () => {
|
||||
expect(getPhysicalDiskAlerts(agentDisk, owners, active, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,13 @@ const buildDisk = (): Resource =>
|
||||
}) as Resource;
|
||||
|
||||
describe('useStoragePoolDetailModel', () => {
|
||||
it('falls back to the record id when the pool has no metrics target', () => {
|
||||
const [record] = createSignal({ ...buildRecord(), metricsTarget: undefined });
|
||||
const [physicalDisks] = createSignal<Resource[]>([]);
|
||||
const { result } = renderHook(() => useStoragePoolDetailModel({ record, physicalDisks }));
|
||||
expect(result.historyTarget()).toEqual({ resourceType: 'storage', resourceId: 'storage-1' });
|
||||
});
|
||||
|
||||
it('builds canonical pool detail model state', () => {
|
||||
const [record] = createSignal(buildRecord());
|
||||
const [physicalDisks] = createSignal<Resource[]>([buildDisk()]);
|
||||
@@ -70,8 +77,8 @@ describe('useStoragePoolDetailModel', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.chartRange()).toBe('7d');
|
||||
expect(result.chartTarget()).toEqual({
|
||||
// The drawer owns its chart range; the model owns what the chart reads.
|
||||
expect(result.historyTarget()).toEqual({
|
||||
resourceType: 'storage',
|
||||
resourceId: 'pool:tank',
|
||||
});
|
||||
@@ -84,6 +91,7 @@ describe('useStoragePoolDetailModel', () => {
|
||||
expect(result.zfsSummary()).toEqual({
|
||||
state: 'ONLINE',
|
||||
scan: 'scrub in progress',
|
||||
scanErrors: 0,
|
||||
errorSummary: null,
|
||||
devices: [{ name: 'sda', type: '', state: '', errorSummary: '', message: '' }],
|
||||
datasets: [],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { guestOverrideIdCandidates } from '@/features/alerts/guestOverrideIdentity';
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getAlertsForUnifiedResource } from '@/utils/alerts';
|
||||
import { getExplicitAgentIdFromResource } from '@/utils/agentResources';
|
||||
|
||||
/**
|
||||
@@ -54,13 +56,94 @@ export const getPhysicalDiskAlertResourceIds = (
|
||||
disk: Resource,
|
||||
owners: ReadonlyMap<string, Resource>,
|
||||
): string[] => {
|
||||
const machine = findPhysicalDiskReportingMachine(disk, owners);
|
||||
return machine ? getAgentMachineAlertOverrideKeys(machine) : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* The machine whose Pulse agent reports a disk: the agent machine the disk is
|
||||
* parented to or, for an Unraid array or cache disk parented to its pool, the
|
||||
* pool's machine. Null when no agent reports it or its machine is not loaded.
|
||||
*/
|
||||
export const findPhysicalDiskReportingMachine = (
|
||||
disk: Resource,
|
||||
owners: ReadonlyMap<string, Resource>,
|
||||
): Resource | null => {
|
||||
let parentId = disk.parentId?.trim();
|
||||
for (let hops = 0; parentId && hops < 2; hops += 1) {
|
||||
const parent = owners.get(parentId);
|
||||
if (!parent) break;
|
||||
if (getExplicitAgentIdFromResource(parent)) return getAgentMachineAlertOverrideKeys(parent);
|
||||
if (getExplicitAgentIdFromResource(parent)) return parent;
|
||||
if (parent.type !== 'storage') break;
|
||||
parentId = parent.parentId?.trim();
|
||||
}
|
||||
return [];
|
||||
return null;
|
||||
};
|
||||
|
||||
// The backend's sanitizeHostComponent: lower case, runs of anything but
|
||||
// letters and digits become one hyphen, trimmed of hyphens.
|
||||
const sanitizeHostAlertComponent = (value: string): string => {
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return sanitized || 'unknown';
|
||||
};
|
||||
|
||||
const ALERT_LEVEL_RANK: Record<Alert['level'], number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
/**
|
||||
* The alert keys a physical disk answers to beyond its unified resource keys:
|
||||
* the path-shaped key Proxmox disk health alerts keep ("<instance>:<node>:
|
||||
* disk:<device>"), which the registry records among the disk's aliases, and
|
||||
* the SMART temperature alert of the agent that reports it
|
||||
* ("agent:<host>/disk_temp:<device>").
|
||||
*/
|
||||
export const getPhysicalDiskExtraAlertKeys = (
|
||||
disk: Resource,
|
||||
owners: ReadonlyMap<string, Resource>,
|
||||
): string[] => {
|
||||
const keys = new Set<string>(
|
||||
(disk.canonicalIdentity?.aliases ?? [])
|
||||
.map((alias) => alias.trim())
|
||||
.filter((alias) => alias.includes(':disk:')),
|
||||
);
|
||||
const machine = findPhysicalDiskReportingMachine(disk, owners);
|
||||
const agentId = machine ? getExplicitAgentIdFromResource(machine)?.trim() : '';
|
||||
const devPath = disk.physicalDisk?.devPath?.trim() ?? '';
|
||||
if (agentId && devPath) {
|
||||
const prefix = `${agentId.startsWith('agent:') ? agentId : `agent:${agentId}`}/disk_temp:`;
|
||||
// The agent names a disk by its block device ("sda"); accept the full
|
||||
// path too so either spelling reaches the same disk.
|
||||
for (const device of [devPath, devPath.replace(/^\/dev\//, '')]) {
|
||||
keys.add(prefix + sanitizeHostAlertComponent(device));
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
};
|
||||
|
||||
/**
|
||||
* Open alerts on one physical disk, most severe first: those keyed on the
|
||||
* disk's own unified identity, its Proxmox disk alert key and its agent's
|
||||
* SMART temperature key for this device.
|
||||
*/
|
||||
export const getPhysicalDiskAlerts = (
|
||||
disk: Resource,
|
||||
owners: ReadonlyMap<string, Resource>,
|
||||
activeAlerts: Record<string, Alert>,
|
||||
alertsEnabled: boolean,
|
||||
): Alert[] => {
|
||||
if (!alertsEnabled) return [];
|
||||
const own = getAlertsForUnifiedResource(disk, activeAlerts, alertsEnabled);
|
||||
const extraKeys = new Set(getPhysicalDiskExtraAlertKeys(disk, owners));
|
||||
const ownIds = new Set(own.map((alert) => alert.id));
|
||||
const extra = Object.values(activeAlerts).filter(
|
||||
(alert) => !ownIds.has(alert.id) && extraKeys.has(alert.resourceId),
|
||||
);
|
||||
return [...own, ...extra].sort(
|
||||
(a, b) =>
|
||||
(ALERT_LEVEL_RANK[a.level] ?? 3) - (ALERT_LEVEL_RANK[b.level] ?? 3) ||
|
||||
Date.parse(a.startTime) - Date.parse(b.startTime),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Accessor, createMemo } from 'solid-js';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { Alert } from '@/types/api';
|
||||
import {
|
||||
asStorageAlertRecord,
|
||||
EMPTY_STORAGE_ALERT_STATE,
|
||||
@@ -19,7 +20,35 @@ type UseStorageAlertStateOptions = {
|
||||
alertsEnabled: Accessor<boolean>;
|
||||
};
|
||||
|
||||
const ALERT_LEVEL_RANK: Record<Alert['level'], number> = { critical: 0, warning: 1, info: 2 };
|
||||
|
||||
const sortAlertsBySeverity = (alerts: Alert[]): Alert[] =>
|
||||
[...alerts].sort(
|
||||
(a, b) =>
|
||||
(ALERT_LEVEL_RANK[a.level] ?? 3) - (ALERT_LEVEL_RANK[b.level] ?? 3) ||
|
||||
Date.parse(a.startTime) - Date.parse(b.startTime),
|
||||
);
|
||||
|
||||
export const useStorageAlertState = (options: UseStorageAlertStateOptions) => {
|
||||
// One match per row: the alerts that tint and explain a row are the alerts
|
||||
// its drawer lists under Needs attention.
|
||||
const alertsByRecordId = createMemo<Record<string, Alert[]>>(() => {
|
||||
const records = options.records();
|
||||
const activeAlerts = asStorageAlertRecord(
|
||||
typeof options.activeAlerts === 'function'
|
||||
? (options.activeAlerts as Accessor<unknown>)()
|
||||
: options.activeAlerts,
|
||||
);
|
||||
const enabled = options.alertsEnabled();
|
||||
const byRecordId: Record<string, Alert[]> = {};
|
||||
for (const record of records) {
|
||||
byRecordId[record.id] = sortAlertsBySeverity(
|
||||
getAlertsForResource(getStorageRecordAlertResourceIds(record), activeAlerts, enabled),
|
||||
);
|
||||
}
|
||||
return byRecordId;
|
||||
});
|
||||
|
||||
const alertStateByRecordId = createMemo<Record<string, StorageAlertRowState>>(() => {
|
||||
const records = options.records();
|
||||
const activeAlerts = asStorageAlertRecord(
|
||||
@@ -45,9 +74,7 @@ export const useStorageAlertState = (options: UseStorageAlertStateOptions) => {
|
||||
hasAcknowledgedOnlyAlert: styles.hasAcknowledgedOnlyAlert,
|
||||
});
|
||||
}
|
||||
const headlineAlert = pickStorageHeadlineAlert(
|
||||
getAlertsForResource(candidateIds, activeAlerts, enabled),
|
||||
);
|
||||
const headlineAlert = pickStorageHeadlineAlert(alertsByRecordId()[record.id] ?? []);
|
||||
byRecordId[record.id] = {
|
||||
...merged,
|
||||
headline: headlineAlert ? describeStorageAlertHeadline(headlineAlert) : null,
|
||||
@@ -63,8 +90,11 @@ export const useStorageAlertState = (options: UseStorageAlertStateOptions) => {
|
||||
const getRecordAlertState = (recordId: string): StorageAlertRowState =>
|
||||
alertStateByRecordId()[recordId] || EMPTY_STORAGE_ALERT_STATE;
|
||||
|
||||
const getRecordAlerts = (recordId: string): Alert[] => alertsByRecordId()[recordId] ?? [];
|
||||
|
||||
return {
|
||||
alertStateByRecordId,
|
||||
getRecordAlertState,
|
||||
getRecordAlerts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -47,7 +47,7 @@ export const useStoragePageData = (options: UseStoragePageDataOptions) => {
|
||||
// Alert state follows the rows the table shows, so a Ceph pool folded into
|
||||
// its mounting storage still explains and ranks that row.
|
||||
const alertRecords = createMemo(() => consolidateCephClusterPoolRecords(records()));
|
||||
const { getRecordAlertState } = useStorageAlertState({
|
||||
const { getRecordAlertState, getRecordAlerts } = useStorageAlertState({
|
||||
records: alertRecords,
|
||||
activeAlerts: options.activeAlerts,
|
||||
alertsEnabled: options.alertsEnabled,
|
||||
@@ -107,6 +107,7 @@ export const useStoragePageData = (options: UseStoragePageDataOptions) => {
|
||||
return {
|
||||
records,
|
||||
getRecordAlertState,
|
||||
getRecordAlerts,
|
||||
nodeOptions,
|
||||
diskNodeOptions,
|
||||
nodeOnlineByLabel,
|
||||
|
||||
@@ -52,6 +52,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
nodes,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
getDiskAlerts,
|
||||
cephResources,
|
||||
alertsEnabled,
|
||||
} = useStoragePageResources({ resourceSource: options.resourceSource });
|
||||
@@ -107,6 +108,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
const {
|
||||
records,
|
||||
getRecordAlertState,
|
||||
getRecordAlerts,
|
||||
nodeOptions,
|
||||
diskNodeOptions,
|
||||
nodeOnlineByLabel,
|
||||
@@ -315,6 +317,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
nodeOptions,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
getDiskAlerts,
|
||||
nodes,
|
||||
groupedRecords,
|
||||
expandedGroups,
|
||||
@@ -324,6 +327,7 @@ export const useStoragePageModel = (options: UseStoragePageModelOptions = {}) =>
|
||||
nodeOnlineByLabel,
|
||||
highlightedRecordId,
|
||||
getRecordAlertState,
|
||||
getRecordAlerts,
|
||||
hoveredStorageResourceId,
|
||||
isLoadingPools,
|
||||
selectedDiskId,
|
||||
|
||||
@@ -3,7 +3,13 @@ import { useWebSocket } from '@/contexts/appRuntime';
|
||||
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
|
||||
import { useAlertsActivation } from '@/stores/alertsActivation';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
import { getPhysicalDiskAlertResourceIds, indexPhysicalDiskOwners } from './physicalDiskAlertOwner';
|
||||
import { asStorageAlertRecord } from '@/features/storageBackups/storageAlertState';
|
||||
import type { Alert } from '@/types/api';
|
||||
import {
|
||||
getPhysicalDiskAlertResourceIds,
|
||||
getPhysicalDiskAlerts,
|
||||
indexPhysicalDiskOwners,
|
||||
} from './physicalDiskAlertOwner';
|
||||
|
||||
const STORAGE_PAGE_RESOURCES_QUERY = 'type=agent,pbs,storage,physical_disk,ceph';
|
||||
|
||||
@@ -49,6 +55,15 @@ export const useStoragePageResources = (options: UseStoragePageResourcesOptions
|
||||
const getDiskAlertResourceIds = (disk: Resource): string[] =>
|
||||
getPhysicalDiskAlertResourceIds(disk, physicalDiskOwners());
|
||||
const alertsEnabled = alertsActivation.detectionEnabled;
|
||||
// A disk drawer lists the disk's own open alerts: those on its unified
|
||||
// identity, its Proxmox disk key and its agent's temperature key.
|
||||
const getDiskAlerts = (disk: Resource): Alert[] =>
|
||||
getPhysicalDiskAlerts(
|
||||
disk,
|
||||
physicalDiskOwners(),
|
||||
asStorageAlertRecord(activeAlerts),
|
||||
alertsEnabled(),
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
@@ -60,6 +75,7 @@ export const useStoragePageResources = (options: UseStoragePageResourcesOptions
|
||||
nodes,
|
||||
physicalDisks,
|
||||
getDiskAlertResourceIds,
|
||||
getDiskAlerts,
|
||||
cephResources,
|
||||
storageResources,
|
||||
alertsEnabled,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Accessor, createMemo, createSignal } from 'solid-js';
|
||||
import type { HistoryTimeRange } from '@/api/charts';
|
||||
import { Accessor, createMemo } from 'solid-js';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import type { PhysicalDiskAlertResourceIdResolver } from '@/features/storageBackups/diskPresentation';
|
||||
import {
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
getStoragePoolLinkedDisks,
|
||||
resolveStoragePoolDetailChartTarget,
|
||||
} from '@/features/storageBackups/storagePoolDetailPresentation';
|
||||
import type { GuestDrawerHistoryTarget } from '@/components/Workloads/guestDrawerModel';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
type UseStoragePoolDetailModelOptions = {
|
||||
@@ -18,9 +18,16 @@ type UseStoragePoolDetailModelOptions = {
|
||||
};
|
||||
|
||||
export const useStoragePoolDetailModel = (options: UseStoragePoolDetailModelOptions) => {
|
||||
const [chartRange, setChartRange] = createSignal<HistoryTimeRange>('7d');
|
||||
|
||||
const chartTarget = createMemo(() => resolveStoragePoolDetailChartTarget(options.record()));
|
||||
// The pool's capacity history: its canonical metrics target, else its own
|
||||
// id. Snapshot replacement makes a new target with the same coordinates;
|
||||
// the drawer chart's query only re-reads on a real change of coordinates.
|
||||
const historyTarget = createMemo((): GuestDrawerHistoryTarget => {
|
||||
const target = resolveStoragePoolDetailChartTarget(options.record());
|
||||
return {
|
||||
resourceType: target.resourceType,
|
||||
resourceId: target.resourceId || options.record().id,
|
||||
};
|
||||
});
|
||||
const configRows = createMemo(() => buildStoragePoolDetailConfigRows(options.record()));
|
||||
const zfsSummary = createMemo(() => buildStoragePoolDetailZfsSummary(options.record()));
|
||||
const linkedDisks = createMemo(() =>
|
||||
@@ -35,9 +42,7 @@ export const useStoragePoolDetailModel = (options: UseStoragePoolDetailModelOpti
|
||||
);
|
||||
|
||||
return {
|
||||
chartRange,
|
||||
setChartRange,
|
||||
chartTarget,
|
||||
historyTarget,
|
||||
configRows,
|
||||
topologyRows,
|
||||
zfsSummary,
|
||||
|
||||
@@ -120,6 +120,8 @@ const ALERT_STATED_ROW_LABELS: Record<string, string> = {
|
||||
'docker-host-offline': 'Observed state',
|
||||
'host-offline': 'Observed state',
|
||||
offline: 'Observed state',
|
||||
'disk-health': 'Health',
|
||||
'disk-wearout': 'Health',
|
||||
};
|
||||
|
||||
export const getAlertStatedRowLabels = (alerts: readonly { type: string }[]): Set<string> =>
|
||||
|
||||
@@ -740,7 +740,6 @@ describe('shared primitive guardrails', () => {
|
||||
'src/components/Settings/SystemLogsPanel.tsx',
|
||||
'src/components/Storage/DiskDetail.tsx',
|
||||
'src/components/Storage/StoragePageControls.tsx',
|
||||
'src/components/Storage/StoragePoolDetail.tsx',
|
||||
'src/components/Workloads/GuestDrawerHistory.tsx',
|
||||
'src/components/shared/FilterBar/AddFilterMenu.tsx',
|
||||
'src/components/shared/FilterToolbar.tsx',
|
||||
@@ -1333,8 +1332,11 @@ describe('shared primitive guardrails', () => {
|
||||
expect(resourceDetailDrawerSource).toContain('@/components/shared/Subtabs');
|
||||
expect(resourceDetailDrawerSource).toContain('tabs={drawer.tabs().map');
|
||||
|
||||
// The pool drawer charts usage with the shared drawer history (range picker
|
||||
// in its toolbar); the guest drawer's embedded disk detail keeps its own.
|
||||
expect(diskDetailSource).toContain('filterSelectClass');
|
||||
expect(storagePoolDetailSource).toContain('GuestDrawerHistory');
|
||||
for (const storageDetailSource of [diskDetailSource, storagePoolDetailSource]) {
|
||||
expect(storageDetailSource).toContain('filterSelectClass');
|
||||
expect(storageDetailSource).not.toContain('activeTab()');
|
||||
expect(storageDetailSource).not.toContain('STORAGE_DETAIL_SELECT_CLASS');
|
||||
expect(storageDetailSource).not.toContain('STORAGE_DETAIL_HEADER_SELECT_CLASS');
|
||||
@@ -7974,9 +7976,9 @@ describe('shared primitive guardrails', () => {
|
||||
expect(resourceDetailDrawerVmwareModelSource).not.toContain('const formatCount');
|
||||
|
||||
expect(resourceDetailDrawerOverviewTabSource).toContain('DetailSectionTable');
|
||||
expect(resourceDetailDrawerOverviewTabSource).toContain(
|
||||
'<DetailSectionTable sections={props.drawer.vmwareDetailSections()} />',
|
||||
);
|
||||
// vSphere sections join the one Overview split (lifted rows, cards,
|
||||
// Details) instead of a nested vSphere disclosure of their own.
|
||||
expect(resourceDetailDrawerOverviewTabSource).toContain('...drawer.vmwareDetailSections(),');
|
||||
expect(resourceDetailDrawerOverviewTabSource).not.toContain('TrueNASDetailSectionTable');
|
||||
expect(resourceDetailDrawerOverviewTabSource).not.toContain('vmwareRowToneClass');
|
||||
expect(resourceDetailDrawerOverviewTabSource).not.toContain(
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
STORAGE_DETAIL_CELL_CLASS,
|
||||
STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS,
|
||||
STORAGE_DETAIL_EMPTY_CLASS,
|
||||
STORAGE_DETAIL_HEADER_ROW_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS,
|
||||
STORAGE_DETAIL_LINKED_DISKS_LIST_CLASS,
|
||||
STORAGE_DETAIL_MUTED_TEXT_CLASS,
|
||||
STORAGE_DETAIL_ROOT_GRID_CLASS,
|
||||
STORAGE_DETAIL_ROW_CLASS,
|
||||
STORAGE_DISK_DETAIL_ATTRIBUTE_GRID_CLASS,
|
||||
STORAGE_DISK_DETAIL_HEADER_CLASS,
|
||||
STORAGE_DISK_DETAIL_HISTORY_GRID_CLASS,
|
||||
@@ -18,15 +12,9 @@ import {
|
||||
} from '@/features/storageBackups/detailPresentation';
|
||||
|
||||
describe('detailPresentation', () => {
|
||||
it('exports canonical storage detail shell classes', () => {
|
||||
expect(STORAGE_DETAIL_ROW_CLASS).toBe('border-t border-border');
|
||||
expect(STORAGE_DETAIL_CELL_CLASS).toBe('bg-surface-alt px-4 py-4');
|
||||
expect(STORAGE_DETAIL_ROOT_GRID_CLASS).toContain('md:grid-cols-2');
|
||||
it('exports the embedded disk detail shell classes', () => {
|
||||
expect(STORAGE_DETAIL_HEADER_ROW_CLASS).toContain('justify-between');
|
||||
expect(STORAGE_DETAIL_LINKED_DISKS_LIST_CLASS).toBe('space-y-1');
|
||||
expect(STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS).toBe('col-span-2');
|
||||
expect(STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS).toContain('flex-1');
|
||||
expect(STORAGE_DETAIL_MUTED_TEXT_CLASS).toBe('text-muted');
|
||||
expect(STORAGE_DETAIL_EMPTY_CLASS).toContain('text-muted');
|
||||
expect(STORAGE_DISK_DETAIL_HEADER_CLASS).toContain('border-b');
|
||||
expect(STORAGE_DISK_DETAIL_MODEL_CLASS).toContain('font-semibold');
|
||||
expect(STORAGE_DISK_DETAIL_NODE_CLASS).toBe('text-muted');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildDiskDrawerActivitySection,
|
||||
buildDiskDrawerHealthSection,
|
||||
getDiskDrawerHistoryGroups,
|
||||
isDiskDrawerIOAvailable,
|
||||
} from '@/features/storageBackups/diskDrawerPresentation';
|
||||
import {
|
||||
getPhysicalDiskHealthIndicator,
|
||||
getPhysicalDiskHealthStatus,
|
||||
type PhysicalDiskPresentationData,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
|
||||
const disk = (
|
||||
overrides: Partial<PhysicalDiskPresentationData> = {},
|
||||
): PhysicalDiskPresentationData => ({
|
||||
node: 'tower',
|
||||
instance: 'lab',
|
||||
devPath: '/dev/sda',
|
||||
model: 'Archive HDD',
|
||||
serial: 'S1',
|
||||
wwn: '',
|
||||
size: 4_000_000_000_000,
|
||||
health: 'PASSED',
|
||||
riskReasons: [],
|
||||
wearout: -1,
|
||||
type: 'hdd',
|
||||
temperature: 40,
|
||||
rpm: 7200,
|
||||
used: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const metrics = (groups: ReturnType<typeof getDiskDrawerHistoryGroups>) =>
|
||||
groups.flatMap((group) => group.series.map((series) => series.metric));
|
||||
|
||||
describe('disk drawer presentation', () => {
|
||||
it('gives the drawer dot the Health cell verdict, judged the same way', () => {
|
||||
for (const [data, variant] of [
|
||||
[disk(), 'success'],
|
||||
[disk({ health: 'FAILED' }), 'danger'],
|
||||
[disk({ smartAttributes: { reallocatedSectors: 4 } }), 'warning'],
|
||||
[disk({ health: 'UNKNOWN' }), 'muted'],
|
||||
] as const) {
|
||||
expect(getPhysicalDiskHealthIndicator(data)).toEqual({
|
||||
variant,
|
||||
label: getPhysicalDiskHealthStatus(data).label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('charts temperature first, then I/O when collected, then the wear counter for the kind', () => {
|
||||
expect(metrics(getDiskDrawerHistoryGroups(disk(), true))).toEqual([
|
||||
'smart_temp',
|
||||
'diskread',
|
||||
'diskwrite',
|
||||
'disk',
|
||||
'smart_reallocated_sectors',
|
||||
]);
|
||||
expect(metrics(getDiskDrawerHistoryGroups(disk({ type: 'nvme' }), false))).toEqual([
|
||||
'smart_temp',
|
||||
'smart_percentage_used',
|
||||
'smart_available_spare',
|
||||
]);
|
||||
// Stable panels: an ordinary snapshot does not remount the charts.
|
||||
expect(getDiskDrawerHistoryGroups(disk(), true)).toBe(getDiskDrawerHistoryGroups(disk(), true));
|
||||
expect(isDiskDrawerIOAvailable(disk())).toBe(true);
|
||||
expect(isDiskDrawerIOAvailable(disk({ collection: { io: { state: 'unsupported' } } }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('tones SSD life and a disk error count so the drawer lifts them', () => {
|
||||
const health = buildDiskDrawerHealthSection(disk({ type: 'ssd', wearout: 30 }));
|
||||
expect(health.rows.find((row) => row.label === 'Life remaining')).toMatchObject({
|
||||
value: '30%',
|
||||
tone: 'warning',
|
||||
});
|
||||
const activity = buildDiskDrawerActivitySection(
|
||||
disk({ readCount: 1200, writeCount: 30, errorCount: 3 }),
|
||||
);
|
||||
expect(activity.rows.find((row) => row.label === 'Errors')).toMatchObject({
|
||||
value: '3',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(buildDiskDrawerActivitySection(disk()).rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a wear verdict state worn flash once instead of lifting the life left again', () => {
|
||||
const worn = disk({ type: 'ssd', wearout: 7 });
|
||||
expect(['warning', 'danger']).toContain(getPhysicalDiskHealthIndicator(worn).variant);
|
||||
const health = buildDiskDrawerHealthSection(worn);
|
||||
expect(health.rows.find((row) => row.label === 'Health')?.tone).toMatch(/warning|danger/);
|
||||
expect(health.rows.find((row) => row.label === 'Life remaining')).toMatchObject({
|
||||
value: '7%',
|
||||
tone: 'default',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getCompactStoragePoolProtectionTitle,
|
||||
getStoragePoolIssueTextClass,
|
||||
getStoragePoolProtectionTextClass,
|
||||
getStoragePoolStateIndicator,
|
||||
getStoragePoolStateLabel,
|
||||
getStoragePoolStateTextClass,
|
||||
} from '@/features/storageBackups/rowPresentation';
|
||||
@@ -158,3 +159,42 @@ describe('storage row presentation', () => {
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('2 read, 1 checksum errors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoragePoolStateIndicator', () => {
|
||||
it("gives the pool drawer's dot the State cell's word and judgement", () => {
|
||||
const withState = (state: string, freshness?: StorageRecord['freshness']) =>
|
||||
({
|
||||
...baseRecord(),
|
||||
freshness,
|
||||
details: {
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state,
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
}) as StorageRecord;
|
||||
expect(getStoragePoolStateIndicator(withState('ONLINE'))).toEqual({
|
||||
variant: 'success',
|
||||
label: 'Online',
|
||||
});
|
||||
expect(getStoragePoolStateIndicator(withState('DEGRADED'))).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Degraded',
|
||||
});
|
||||
expect(getStoragePoolStateIndicator(withState('FAULTED'))).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Faulted',
|
||||
});
|
||||
expect(getStoragePoolStateIndicator(withState('ONLINE', 'stale'))).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Stale',
|
||||
});
|
||||
// The cell colour and the dot read one judgement.
|
||||
expect(getStoragePoolStateTextClass(withState('FAULTED'))).toContain('text-red-700');
|
||||
expect(getStoragePoolStateTextClass(withState('DEGRADED'))).toContain('text-amber-700');
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -256,6 +256,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
|
||||
expect(buildStoragePoolDetailZfsSummary(record)).toEqual({
|
||||
state: 'ONLINE',
|
||||
scan: '',
|
||||
scanErrors: 0,
|
||||
errorSummary: null,
|
||||
devices: [],
|
||||
datasets: [],
|
||||
@@ -278,6 +279,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
|
||||
expect(buildStoragePoolDetailZfsSummary(record)).toEqual({
|
||||
state: 'DEGRADED',
|
||||
scan: '',
|
||||
scanErrors: 0,
|
||||
errorSummary: null,
|
||||
devices: [],
|
||||
datasets: [],
|
||||
@@ -300,6 +302,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
|
||||
expect(buildStoragePoolDetailZfsSummary(record)).toEqual({
|
||||
state: 'ONLINE',
|
||||
scan: 'scrub ok',
|
||||
scanErrors: 0,
|
||||
errorSummary: null,
|
||||
devices: [{ name: 'sdc', type: '', state: '', errorSummary: '', message: '' }],
|
||||
datasets: [],
|
||||
@@ -322,6 +325,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
|
||||
expect(buildStoragePoolDetailZfsSummary(record)).toEqual({
|
||||
state: 'FAULTED',
|
||||
scan: '',
|
||||
scanErrors: 0,
|
||||
errorSummary: 'Errors: R:1 W:0 C:0',
|
||||
devices: [],
|
||||
datasets: [],
|
||||
|
||||
+1
@@ -200,6 +200,7 @@ describe('storagePoolDetailPresentation', () => {
|
||||
expect(buildStoragePoolDetailZfsSummary(record)).toEqual({
|
||||
state: 'ONLINE',
|
||||
scan: 'scrub repaired 0B',
|
||||
scanErrors: 0,
|
||||
errorSummary: 'Errors: R:1 W:2 C:3',
|
||||
devices: [
|
||||
{ name: 'sda', type: 'disk', state: 'ONLINE', errorSummary: '', message: '' },
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
buildStoragePoolDetailZfsSummary,
|
||||
type StoragePoolDetailLinkedDisk,
|
||||
} from '@/features/storageBackups/storagePoolDetailPresentation';
|
||||
import {
|
||||
buildStoragePoolDrawerDetailSections,
|
||||
buildStoragePoolDrawerExceptionRows,
|
||||
buildStoragePoolDrawerZfsSections,
|
||||
getStoragePoolDrawerFacts,
|
||||
} from '@/features/storageBackups/storagePoolDrawerPresentation';
|
||||
|
||||
const record = (overrides: Partial<StorageRecord> = {}): StorageRecord => ({
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
category: 'pool',
|
||||
health: 'healthy',
|
||||
location: { label: 'truenas01', scope: 'host' },
|
||||
capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 },
|
||||
capabilities: ['capacity', 'health'],
|
||||
source: { platform: 'truenas', family: 'onprem', origin: 'resource', adapterId: 'resource' },
|
||||
observedAt: Date.now(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const linkedDisk = (overrides: Partial<StoragePoolDetailLinkedDisk> = {}) =>
|
||||
({
|
||||
id: 'disk-1',
|
||||
devPath: '/dev/sda',
|
||||
model: 'Disk A',
|
||||
role: '',
|
||||
state: '',
|
||||
sizeLabel: '',
|
||||
diskType: 'hdd',
|
||||
alertResourceIds: [],
|
||||
temperature: 0,
|
||||
hasIssue: false,
|
||||
spunDown: false,
|
||||
errorCount: 0,
|
||||
ioLabel: '',
|
||||
...overrides,
|
||||
}) satisfies StoragePoolDetailLinkedDisk;
|
||||
|
||||
describe('storage pool drawer presentation', () => {
|
||||
it('lists nothing worth checking for a healthy pool', () => {
|
||||
expect(
|
||||
buildStoragePoolDrawerExceptionRows({
|
||||
record: record(),
|
||||
zfsSummary: null,
|
||||
linkedDisks: [linkedDisk({ temperature: 35 })],
|
||||
getDiskHeat: () => null,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lifts reduced protection, disk errors and hot disks, but not a retained reading', () => {
|
||||
const rows = buildStoragePoolDrawerExceptionRows({
|
||||
record: record({ protectionReduced: true, protectionSummary: 'One mirror member missing' }),
|
||||
zfsSummary: null,
|
||||
linkedDisks: [
|
||||
linkedDisk({ errorCount: 2 }),
|
||||
linkedDisk({ devPath: '/dev/sdb', hasIssue: true }),
|
||||
linkedDisk({ devPath: '/dev/sdc', temperature: 66 }),
|
||||
linkedDisk({
|
||||
devPath: '/dev/sdd',
|
||||
temperature: 70,
|
||||
temperatureLastKnownTitle: 'Last known reading, not current',
|
||||
}),
|
||||
],
|
||||
getDiskHeat: (disk) => (disk.temperature >= 65 ? 'critical' : null),
|
||||
});
|
||||
expect(rows.map((row) => [row.label, row.value, row.tone])).toEqual([
|
||||
['Protection', 'One mirror member missing', 'danger'],
|
||||
['Disk', '/dev/sda: 2 errors', 'warning'],
|
||||
['Disk', '/dev/sdb: reallocated sectors', 'warning'],
|
||||
['Disk temperature', '/dev/sdc: 66°C', 'danger'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the whole device tree in front and moves pool counters into Worth checking', () => {
|
||||
const zfs = buildStoragePoolDetailZfsSummary(
|
||||
record({
|
||||
details: {
|
||||
zfsPool: {
|
||||
name: 'tank',
|
||||
state: 'ONLINE',
|
||||
scan: 'scrub in progress',
|
||||
readErrors: 1,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
devices: [
|
||||
{ name: 'mirror-0', type: 'mirror', state: 'ONLINE' },
|
||||
{ name: 'sdb', type: 'disk', state: 'DEGRADED' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const sections = buildStoragePoolDrawerZfsSections(zfs);
|
||||
expect(sections.map((section) => section.label)).toEqual(['ZFS pool', 'ZFS devices (2)']);
|
||||
expect(sections[0].rows.map((row) => row.label)).toEqual(['State', 'Scan']);
|
||||
expect(sections[1].rows.map((row) => [row.label, row.tone, row.layout, row.wrap])).toEqual([
|
||||
['mirror-0', 'default', 'stacked', true],
|
||||
['sdb', 'warning', 'stacked', true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps configuration, topology and datasets for Details', () => {
|
||||
const sections = buildStoragePoolDrawerDetailSections({
|
||||
configRows: [{ label: 'Node', value: 'truenas01' }],
|
||||
topologyRows: [],
|
||||
zfsSummary: null,
|
||||
});
|
||||
expect(sections.map((section) => section.label)).toEqual(['Configuration']);
|
||||
expect(sections[0].rows[0]).toMatchObject({ label: 'Node', value: 'truenas01', wrap: true });
|
||||
});
|
||||
|
||||
it('states facts plainly and never a used amount for a stale pool', () => {
|
||||
expect(getStoragePoolDrawerFacts(record())).toEqual([
|
||||
'pool',
|
||||
'400 B of 1000 B used',
|
||||
'On truenas01',
|
||||
]);
|
||||
expect(getStoragePoolDrawerFacts(record({ freshness: 'stale' }))).toContain('1000 B total');
|
||||
// Proxmox lists content types comma-separated without spaces.
|
||||
expect(getStoragePoolDrawerFacts(record({ details: { content: 'images,rootdir' } }))).toContain(
|
||||
'Holds images, rootdir',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import { INFO_CARD_FRAME_CLASS } from '@/components/shared/InfoCardFrame';
|
||||
|
||||
// Classes of the compact disk detail the guest drawer embeds for a guest's
|
||||
// physical disks (DiskDetail). The Storage page's pool and disk drawers are
|
||||
// built from the shared drawer primitives and use none of these.
|
||||
|
||||
export const STORAGE_DETAIL_CARD_CLASS = 'rounded-md border border-border bg-surface p-3 shadow-xs';
|
||||
|
||||
export const STORAGE_DETAIL_SECTION_TITLE_CLASS = 'text-xs font-semibold text-base-content';
|
||||
|
||||
export const STORAGE_DETAIL_SECTION_TITLE_SPACED_CLASS =
|
||||
'text-xs font-semibold text-base-content mb-2';
|
||||
|
||||
export const STORAGE_DETAIL_INLINE_LABEL_CLASS =
|
||||
'text-[10px] font-medium text-muted uppercase tracking-wide';
|
||||
|
||||
@@ -19,24 +20,10 @@ export const STORAGE_DETAIL_META_ROW_CLASS =
|
||||
export const STORAGE_DETAIL_MONO_CHIP_CLASS =
|
||||
'text-muted font-mono bg-surface-alt px-1.5 py-0.5 rounded-sm border border-border';
|
||||
|
||||
export const STORAGE_DETAIL_CONFIG_GRID_CLASS = 'grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]';
|
||||
export const STORAGE_DETAIL_ROW_CLASS = 'border-t border-border';
|
||||
export const STORAGE_DETAIL_CELL_CLASS = 'bg-surface-alt px-4 py-4';
|
||||
export const STORAGE_DETAIL_ROOT_GRID_CLASS = 'grid gap-4 md:grid-cols-2';
|
||||
export const STORAGE_DETAIL_HEADER_ROW_CLASS = 'flex items-center justify-between mb-2';
|
||||
export const STORAGE_DETAIL_LINKED_DISKS_LIST_CLASS = 'space-y-1';
|
||||
|
||||
export const STORAGE_DETAIL_KEY_CLASS = 'text-muted';
|
||||
export const STORAGE_DETAIL_VALUE_CLASS = 'text-base-content font-medium';
|
||||
|
||||
export const STORAGE_DETAIL_LINKED_DISK_ROW_CLASS =
|
||||
'flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] py-0.5';
|
||||
export const STORAGE_DETAIL_LINKED_DISK_PATH_CLASS = 'font-mono text-muted min-w-16 truncate';
|
||||
export const STORAGE_DETAIL_LINKED_DISK_MODEL_CLASS = 'text-base-content min-w-32 flex-1 truncate';
|
||||
export const STORAGE_DETAIL_SPACED_STACK_CLASS = 'space-y-3';
|
||||
export const STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS = 'col-span-2';
|
||||
export const STORAGE_DETAIL_MUTED_TEXT_CLASS = 'text-muted';
|
||||
|
||||
export const STORAGE_DETAIL_EMPTY_CLASS = `${INFO_CARD_FRAME_CLASS} text-center text-[11px] text-muted`;
|
||||
|
||||
export const STORAGE_DISK_DETAIL_ROOT_CLASS = 'space-y-3';
|
||||
|
||||
@@ -22,15 +22,24 @@ export function getLinkedDiskHealthDotVariant(hasIssue: boolean): StatusIndicato
|
||||
return hasIssue ? 'warning' : 'success';
|
||||
}
|
||||
|
||||
/** How hot a linked disk's current reading is under its disk heat policy. */
|
||||
export function getLinkedDiskTemperatureSeverity(
|
||||
tempCelsius: number,
|
||||
thresholds?: MetricDisplayThresholds | null,
|
||||
): 'critical' | 'warning' | null {
|
||||
// Null thresholds mean disk temperature alerting is off for this disk.
|
||||
if (!Number.isFinite(tempCelsius) || tempCelsius <= 0 || thresholds === null) {
|
||||
return null;
|
||||
}
|
||||
const severity = getMetricSeverity(tempCelsius, 'diskTemperature', thresholds);
|
||||
return severity === 'critical' || severity === 'warning' ? severity : null;
|
||||
}
|
||||
|
||||
export function getLinkedDiskTemperatureTextClass(
|
||||
tempCelsius: number,
|
||||
thresholds?: MetricDisplayThresholds | null,
|
||||
): string {
|
||||
// Null thresholds mean disk temperature alerting is off for this disk.
|
||||
if (!Number.isFinite(tempCelsius) || tempCelsius <= 0 || thresholds === null) {
|
||||
return 'text-muted';
|
||||
}
|
||||
const severity = getMetricSeverity(tempCelsius, 'diskTemperature', thresholds);
|
||||
const severity = getLinkedDiskTemperatureSeverity(tempCelsius, thresholds);
|
||||
if (severity === 'critical') {
|
||||
return 'text-red-500';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
formatDetailIntegerValue,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import type {
|
||||
GuestDrawerHistoryDeferredMetric,
|
||||
GuestDrawerHistoryGroupConfig,
|
||||
} from '@/components/Workloads/guestDrawerModel';
|
||||
import {
|
||||
getDiskDetailAttributeCards,
|
||||
type DiskDetailAttributeCard,
|
||||
} from '@/features/storageBackups/diskDetailPresentation';
|
||||
import {
|
||||
getPhysicalDiskCollectionMessages,
|
||||
getPhysicalDiskHealthIndicator,
|
||||
getPhysicalDiskHealthStatus,
|
||||
getPhysicalDiskHealthSummary,
|
||||
getPhysicalDiskHostLabel,
|
||||
getPhysicalDiskLifeLabel,
|
||||
getPhysicalDiskLifeTextClass,
|
||||
getPhysicalDiskParentLabel,
|
||||
getPhysicalDiskRoleLabel,
|
||||
getPhysicalDiskSourceBadgePresentation,
|
||||
getPhysicalDiskTemperaturePresentation,
|
||||
getPhysicalDiskTemperatureThresholds,
|
||||
isPhysicalDiskWearoutReported,
|
||||
type PhysicalDiskPresentationData,
|
||||
} from '@/features/storageBackups/diskPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
|
||||
// The disk drawer charts with the drawer history component, from the series
|
||||
// the metrics store keeps for a physical disk. Temperature leads (it is the
|
||||
// disk's first panel on a phone), then I/O, then the SMART counter that shows
|
||||
// wear for the disk's kind. Module constants keep the chart grid from
|
||||
// remounting on every disk snapshot.
|
||||
const DISK_TEMPERATURE_GROUP: GuestDrawerHistoryGroupConfig = {
|
||||
id: 'disk-thermal',
|
||||
label: 'Temperature',
|
||||
unit: 'C',
|
||||
series: [{ metric: 'smart_temp', label: 'Disk', unit: 'C', color: '#ef4444' }],
|
||||
};
|
||||
|
||||
const DISK_IO_GROUPS: readonly GuestDrawerHistoryGroupConfig[] = [
|
||||
{
|
||||
id: 'disk-io',
|
||||
label: 'Disk I/O',
|
||||
unit: 'B/s',
|
||||
series: [
|
||||
{ metric: 'diskread', label: 'Read', unit: 'B/s', color: '#3b82f6' },
|
||||
{ metric: 'diskwrite', label: 'Write', unit: 'B/s', color: '#f59e0b' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'disk-activity',
|
||||
label: 'Busy',
|
||||
unit: '%',
|
||||
series: [{ metric: 'disk', label: 'Busy', unit: '%', color: '#8b5cf6' }],
|
||||
},
|
||||
];
|
||||
|
||||
const ATA_WEAR_GROUP: GuestDrawerHistoryGroupConfig = {
|
||||
id: 'disk-reallocated',
|
||||
label: 'Reallocated sectors',
|
||||
unit: 'sectors',
|
||||
series: [
|
||||
{
|
||||
metric: 'smart_reallocated_sectors',
|
||||
label: 'Reallocated',
|
||||
unit: 'sectors',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NVME_WEAR_GROUP: GuestDrawerHistoryGroupConfig = {
|
||||
id: 'disk-wear',
|
||||
label: 'Wear',
|
||||
unit: '%',
|
||||
series: [
|
||||
{ metric: 'smart_percentage_used', label: 'Life used', unit: '%', color: '#f59e0b' },
|
||||
{ metric: 'smart_available_spare', label: 'Spare', unit: '%', color: '#10b981' },
|
||||
],
|
||||
};
|
||||
|
||||
const DISK_DRAWER_GROUPS = {
|
||||
ata: [DISK_TEMPERATURE_GROUP, ...DISK_IO_GROUPS, ATA_WEAR_GROUP],
|
||||
ataNoIO: [DISK_TEMPERATURE_GROUP, ATA_WEAR_GROUP],
|
||||
nvme: [DISK_TEMPERATURE_GROUP, ...DISK_IO_GROUPS, NVME_WEAR_GROUP],
|
||||
nvmeNoIO: [DISK_TEMPERATURE_GROUP, NVME_WEAR_GROUP],
|
||||
} satisfies Record<string, GuestDrawerHistoryGroupConfig[]>;
|
||||
|
||||
const isNvme = (disk: PhysicalDiskPresentationData): boolean => disk.type?.toLowerCase() === 'nvme';
|
||||
|
||||
/**
|
||||
* The disk's chart panels. I/O panels are left out where the collector says
|
||||
* per-disk I/O is not available, so no empty chart reads as an idle disk.
|
||||
*/
|
||||
export function getDiskDrawerHistoryGroups(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
ioAvailable: boolean,
|
||||
): GuestDrawerHistoryGroupConfig[] {
|
||||
if (isNvme(disk)) return ioAvailable ? DISK_DRAWER_GROUPS.nvme : DISK_DRAWER_GROUPS.nvmeNoIO;
|
||||
return ioAvailable ? DISK_DRAWER_GROUPS.ata : DISK_DRAWER_GROUPS.ataNoIO;
|
||||
}
|
||||
|
||||
export function isDiskDrawerIOAvailable(disk: PhysicalDiskPresentationData): boolean {
|
||||
const state = disk.collection?.io?.state;
|
||||
return !state || state === 'available';
|
||||
}
|
||||
|
||||
const finite = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
|
||||
/** Current readings the charts show before any history is stored. */
|
||||
export function getDiskDrawerCurrentMetrics(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
): Record<string, number | undefined> {
|
||||
const temperature = getPhysicalDiskTemperaturePresentation(disk);
|
||||
const attrs = disk.smartAttributes ?? {};
|
||||
return {
|
||||
smart_temp: temperature?.current ? disk.temperature : undefined,
|
||||
smart_reallocated_sectors: finite(attrs.reallocatedSectors),
|
||||
smart_percentage_used: finite(attrs.percentageUsed),
|
||||
smart_available_spare: finite(attrs.availableSpare),
|
||||
};
|
||||
}
|
||||
|
||||
/** A retained temperature is charted as last known, never as live. */
|
||||
export function getDiskDrawerDeferredMetrics(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
): Record<string, GuestDrawerHistoryDeferredMetric> | undefined {
|
||||
const temperature = getPhysicalDiskTemperaturePresentation(disk);
|
||||
if (!temperature || temperature.current || !temperature.title) return undefined;
|
||||
return {
|
||||
smart_temp: { lastKnownValue: disk.temperature, message: `${temperature.title}.` },
|
||||
};
|
||||
}
|
||||
|
||||
const lifeTone = (disk: PhysicalDiskPresentationData): DetailValueTone => {
|
||||
if (!isPhysicalDiskWearoutReported(disk)) return 'muted';
|
||||
const textClass = getPhysicalDiskLifeTextClass(disk);
|
||||
if (textClass.includes('red')) return 'danger';
|
||||
if (textClass.includes('amber')) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const attributeTone = (card: DiskDetailAttributeCard): DetailValueTone => {
|
||||
if (card.lastKnown) return 'muted';
|
||||
return card.ok ? 'default' : 'danger';
|
||||
};
|
||||
|
||||
/**
|
||||
* The SMART health card in front: the verdict the Health cell shows with its
|
||||
* reason, the temperature and, for SSDs, the life left.
|
||||
*/
|
||||
export function buildDiskDrawerHealthSection(disk: PhysicalDiskPresentationData): DetailSection {
|
||||
const status = getPhysicalDiskHealthStatus(disk);
|
||||
const indicator = getPhysicalDiskHealthIndicator(disk);
|
||||
const summary = getPhysicalDiskHealthSummary(status);
|
||||
const temperature = getPhysicalDiskTemperaturePresentation(disk);
|
||||
return {
|
||||
label: 'SMART health',
|
||||
testId: 'disk-drawer-health',
|
||||
rows: compactDetailRows([
|
||||
makeDetailRow('Health', summary ? `${status.label}: ${summary}` : status.label, {
|
||||
tone:
|
||||
indicator.variant === 'danger'
|
||||
? 'danger'
|
||||
: indicator.variant === 'warning'
|
||||
? 'warning'
|
||||
: indicator.variant === 'success'
|
||||
? 'success'
|
||||
: 'default',
|
||||
wrap: true,
|
||||
}),
|
||||
temperature
|
||||
? makeDetailRow(
|
||||
temperature.current ? 'Temperature' : 'Last known temperature',
|
||||
temperature.label,
|
||||
// A hot disk is stated by the verdict above; a retained reading
|
||||
// carries no health tone at all.
|
||||
temperature.current ? {} : { tone: 'muted', title: temperature.title },
|
||||
)
|
||||
: null,
|
||||
// Worn flash already turns the verdict above; the life left then stays
|
||||
// in this card instead of repeating the verdict in Worth checking.
|
||||
makeDetailRow('Life remaining', getPhysicalDiskLifeLabel(disk), {
|
||||
tone:
|
||||
indicator.variant === 'warning' || indicator.variant === 'danger'
|
||||
? 'default'
|
||||
: lifeTone(disk),
|
||||
}),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every SMART attribute the disk reports, for Details. An attribute out of
|
||||
* range carries a danger tone, so the drawer lifts it into Worth checking.
|
||||
* Temperature lives in the health card instead.
|
||||
*/
|
||||
export function buildDiskDrawerSmartSection(disk: PhysicalDiskPresentationData): DetailSection {
|
||||
const cards = getDiskDetailAttributeCards(disk, getPhysicalDiskTemperatureThresholds(disk));
|
||||
return {
|
||||
label: 'SMART attributes',
|
||||
testId: 'disk-drawer-smart-attributes',
|
||||
rows: compactDetailRows(
|
||||
cards
|
||||
.filter((card) => card.label !== 'Temperature' && card.label !== 'Last known temperature')
|
||||
.map((card) => makeDetailRow(card.label, card.value, { tone: attributeTone(card) })),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Identity and placement facts operators copy and compare: they wrap. */
|
||||
export function buildDiskDrawerIdentitySection(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
resource: Resource,
|
||||
): DetailSection {
|
||||
return {
|
||||
label: 'Disk',
|
||||
rows: compactDetailRows([
|
||||
makeDetailRow('Model', disk.model, { wrap: true }),
|
||||
makeDetailRow('Vendor', disk.vendor, { wrap: true }),
|
||||
makeDetailRow('Serial', disk.serial, { wrap: true }),
|
||||
makeDetailRow('WWN', disk.wwn, { wrap: true }),
|
||||
makeDetailRow('Device', disk.devPath, { wrap: true }),
|
||||
makeDetailRow('Host', getPhysicalDiskHostLabel(disk, resource), { wrap: true }),
|
||||
makeDetailRow('Source', getPhysicalDiskSourceBadgePresentation(resource).label),
|
||||
makeDetailRow('Type', getPhysicalDiskRoleLabel(disk)),
|
||||
makeDetailRow('Size', disk.size > 0 ? formatBytes(disk.size) : null),
|
||||
makeDetailRow('Speed', disk.rpm > 0 ? `${disk.rpm.toLocaleString()} rpm` : null),
|
||||
makeDetailRow('Belongs to', getPhysicalDiskParentLabel(disk), { wrap: true }),
|
||||
makeDetailRow('State', disk.storageState, { wrap: true }),
|
||||
makeDetailRow('Controller', disk.controller, { wrap: true }),
|
||||
makeDetailRow('Target', disk.target, { wrap: true }),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
/** The disk's own counters; an error count is lifted into Worth checking. */
|
||||
export function buildDiskDrawerActivitySection(disk: PhysicalDiskPresentationData): DetailSection {
|
||||
return {
|
||||
label: 'Activity',
|
||||
rows: compactDetailRows([
|
||||
makeDetailRow('Reads', formatDetailIntegerValue(disk.readCount)),
|
||||
makeDetailRow('Writes', formatDetailIntegerValue(disk.writeCount)),
|
||||
makeDetailRow(
|
||||
'Errors',
|
||||
typeof disk.errorCount === 'number' && disk.errorCount > 0
|
||||
? formatDetailIntegerValue(disk.errorCount)
|
||||
: null,
|
||||
{ tone: 'warning' },
|
||||
),
|
||||
makeDetailRow('Spun down', disk.spunDown ? 'Yes' : null),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDiskDrawerDetailSections(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
resource: Resource,
|
||||
): DetailSection[] {
|
||||
return compactDetailSections([
|
||||
buildDiskDrawerIdentitySection(disk, resource),
|
||||
buildDiskDrawerSmartSection(disk),
|
||||
buildDiskDrawerActivitySection(disk),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the collector could not read, and a disk that reports no SMART detail
|
||||
* at all, each said once in plain words above the charts.
|
||||
*/
|
||||
export function getDiskDrawerNotices(disk: PhysicalDiskPresentationData): string[] {
|
||||
const messages = getPhysicalDiskCollectionMessages(disk);
|
||||
const cards = getDiskDetailAttributeCards(disk, getPhysicalDiskTemperatureThresholds(disk));
|
||||
return cards.length === 0 && messages.length === 0
|
||||
? ['Detailed SMART attributes are not available for this disk.']
|
||||
: messages;
|
||||
}
|
||||
|
||||
const isPlaceholder = (value: string | undefined): boolean => {
|
||||
const trimmed = (value ?? '').trim();
|
||||
return !trimmed || trimmed === '—' || trimmed === '-';
|
||||
};
|
||||
|
||||
/** The short facts strip under the charts. */
|
||||
export function getDiskDrawerFacts(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
resource: Resource,
|
||||
): string[] {
|
||||
const host = getPhysicalDiskHostLabel(disk, resource);
|
||||
return [
|
||||
[getPhysicalDiskRoleLabel(disk), disk.size > 0 ? formatBytes(disk.size) : '']
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
isPlaceholder(host) ? '' : `On ${host}`,
|
||||
disk.devPath,
|
||||
getPhysicalDiskSourceBadgePresentation(resource).label,
|
||||
].filter((fact) => !isPlaceholder(fact));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type MetricDisplayThresholds,
|
||||
} from '@/utils/metricThresholds';
|
||||
import { normalizeStorageSourceKey, storageSourceMatchesFilter } from '@/utils/storageSources';
|
||||
import type { StatusIndicator } from '@/utils/status';
|
||||
import {
|
||||
getPhysicalDiskHeatSummary,
|
||||
isPhysicalDiskRunningHot,
|
||||
@@ -125,8 +126,6 @@ export const PHYSICAL_DISK_TABLE_ROW_CLASS = 'cursor-pointer transition-colors';
|
||||
export const PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS = 'bg-blue-50 dark:bg-blue-900/25';
|
||||
export const PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS = 'hover:bg-surface-hover';
|
||||
export const PHYSICAL_DISK_TABLE_ROW_STYLE = { height: '32px' } as const;
|
||||
export const PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS =
|
||||
'border-b border-border-subtle bg-surface-alt px-4 py-4 shadow-inner';
|
||||
export type PhysicalDiskTableLayoutMode =
|
||||
'narrow' | 'compact' | 'basic' | 'operational' | 'expanded' | 'full';
|
||||
|
||||
@@ -848,6 +847,24 @@ export function getPhysicalDiskHealthStatus(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The disk drawer's status dot: the Health cell's verdict word, judged by the
|
||||
* same verdict that colours that cell.
|
||||
*/
|
||||
export function getPhysicalDiskHealthIndicator(
|
||||
disk: PhysicalDiskPresentationData,
|
||||
): StatusIndicator {
|
||||
const status = getPhysicalDiskHealthStatus(disk);
|
||||
if (status.tone === PHYSICAL_DISK_CRITICAL_TONE)
|
||||
return { variant: 'danger', label: status.label };
|
||||
if (status.tone === PHYSICAL_DISK_WARNING_TONE)
|
||||
return { variant: 'warning', label: status.label };
|
||||
if (status.label === 'Healthy' || status.label === 'Online') {
|
||||
return { variant: 'success', label: status.label };
|
||||
}
|
||||
return { variant: 'muted', label: status.label };
|
||||
}
|
||||
|
||||
export function getPhysicalDiskNormalizedHealth(
|
||||
resource: Resource,
|
||||
disk: PhysicalDiskPresentationData,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StorageRecord } from './models';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import type { StatusIndicator, StatusIndicatorVariant } from '@/utils/status';
|
||||
import {
|
||||
getStorageRecordIssueSummary,
|
||||
getStorageRecordStatus,
|
||||
@@ -46,10 +47,12 @@ export function getStoragePoolIssueTextClass(record: StorageRecord): string {
|
||||
return 'text-base-content';
|
||||
}
|
||||
|
||||
export function getStoragePoolStateTextClass(record: StorageRecord): string {
|
||||
if (record.freshness === 'stale') {
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
}
|
||||
export type StoragePoolStateTone = 'danger' | 'warning' | 'ok' | 'unknown';
|
||||
|
||||
// One judgement of the State cell's word: the cell's colour and the pool
|
||||
// drawer's status dot both read it, so the two cannot disagree.
|
||||
export function getStoragePoolStateTone(record: StorageRecord): StoragePoolStateTone {
|
||||
if (record.freshness === 'stale') return 'warning';
|
||||
const normalized = getStoragePoolStateLabel(record).trim().toLowerCase();
|
||||
if (
|
||||
normalized === 'critical' ||
|
||||
@@ -58,14 +61,38 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string {
|
||||
normalized === 'offline' ||
|
||||
normalized === 'unavailable'
|
||||
) {
|
||||
return 'text-red-700 dark:text-red-300';
|
||||
return 'danger';
|
||||
}
|
||||
if (normalized === 'warning' || normalized === 'warn' || normalized === 'degraded') {
|
||||
return 'text-amber-700 dark:text-amber-300';
|
||||
return 'warning';
|
||||
}
|
||||
if (!normalized || normalized === '—' || normalized === 'unknown') return 'unknown';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export function getStoragePoolStateTextClass(record: StorageRecord): string {
|
||||
const tone = getStoragePoolStateTone(record);
|
||||
if (tone === 'danger') return 'text-red-700 dark:text-red-300';
|
||||
if (tone === 'warning') return 'text-amber-700 dark:text-amber-300';
|
||||
return 'text-base-content';
|
||||
}
|
||||
|
||||
const STORAGE_POOL_STATE_VARIANTS: Record<StoragePoolStateTone, StatusIndicatorVariant> = {
|
||||
danger: 'danger',
|
||||
warning: 'warning',
|
||||
ok: 'success',
|
||||
unknown: 'muted',
|
||||
};
|
||||
|
||||
/** The pool drawer's status dot: the State cell's word and its judgement. */
|
||||
export function getStoragePoolStateIndicator(record: StorageRecord): StatusIndicator {
|
||||
const label = getStoragePoolStateLabel(record);
|
||||
return {
|
||||
variant: STORAGE_POOL_STATE_VARIANTS[getStoragePoolStateTone(record)],
|
||||
label: label === '—' ? 'Unknown' : label,
|
||||
};
|
||||
}
|
||||
|
||||
// The rebuild summary the backend promotes into the protection label is a
|
||||
// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is
|
||||
// a badge, so a ZFS scan reads as the activity word, derived from the pool's
|
||||
|
||||
@@ -63,6 +63,8 @@ export type StoragePoolDetailZfsDevice = {
|
||||
export type StoragePoolDetailZfsSummary = {
|
||||
state: string;
|
||||
scan: string;
|
||||
/** Errors the current or last scrub/resilver found; 0 when none or unreported. */
|
||||
scanErrors: number;
|
||||
errorSummary: string | null;
|
||||
devices: StoragePoolDetailZfsDevice[];
|
||||
datasets: Array<{
|
||||
@@ -262,6 +264,10 @@ export function buildStoragePoolDetailZfsSummary(
|
||||
return {
|
||||
state: pool.state,
|
||||
scan: pool.scan && pool.scan !== 'none' ? pool.scan : '',
|
||||
scanErrors:
|
||||
typeof pool.scanDetails?.errors === 'number' && Number.isFinite(pool.scanDetails.errors)
|
||||
? Math.max(0, pool.scanDetails.errors)
|
||||
: 0,
|
||||
errorSummary: hasErrors
|
||||
? getZfsErrorSummary(pool.readErrors, pool.writeErrors, pool.checksumErrors)
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import {
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
import type {
|
||||
GuestDrawerHistoryDeferredMetric,
|
||||
GuestDrawerHistoryGroupConfig,
|
||||
} from '@/components/Workloads/guestDrawerModel';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
getStorageRecordContent,
|
||||
getStorageRecordHostLabel,
|
||||
getStorageRecordShared,
|
||||
getStorageRecordTopologyLabel,
|
||||
} from '@/features/storageBackups/recordPresentation';
|
||||
import {
|
||||
getStoragePoolStateLabel,
|
||||
getStoragePoolStateTitle,
|
||||
getStoragePoolStateTone,
|
||||
} from '@/features/storageBackups/rowPresentation';
|
||||
import type {
|
||||
StoragePoolDetailConfigRow,
|
||||
StoragePoolDetailLinkedDisk,
|
||||
StoragePoolDetailTopologyRow,
|
||||
StoragePoolDetailZfsSummary,
|
||||
} from '@/features/storageBackups/storagePoolDetailPresentation';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { formatTemperature } from '@/utils/temperature';
|
||||
|
||||
/** A linked disk's current heat under its disk heat policy. */
|
||||
export type StoragePoolLinkedDiskHeat = 'critical' | 'warning' | null;
|
||||
|
||||
// A pool's stored history is its used capacity, the alert's own metric. The
|
||||
// same catalog the resource drawer charts storage with.
|
||||
export const STORAGE_POOL_DRAWER_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] = [
|
||||
{
|
||||
id: 'capacity',
|
||||
label: 'Capacity',
|
||||
unit: '%',
|
||||
series: [{ metric: 'usage', label: 'Used', unit: '%', color: '#22c55e' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const STORAGE_POOL_DRAWER_CHARTED_METRICS = STORAGE_POOL_DRAWER_HISTORY_GROUPS.flatMap(
|
||||
(group) => group.series.map((series) => series.metric),
|
||||
);
|
||||
|
||||
const observed = (value: number | null | undefined): number | null =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
||||
|
||||
const getUsagePercent = (record: StorageRecord): number | null => {
|
||||
const total = observed(record.capacity.totalBytes);
|
||||
const used = observed(record.capacity.usedBytes);
|
||||
const percent =
|
||||
observed(record.capacity.usagePercent) ??
|
||||
(total !== null && total > 0 && used !== null ? (used / total) * 100 : null);
|
||||
return percent === null ? null : Math.min(100, percent);
|
||||
};
|
||||
|
||||
const STALE_POOL_MESSAGE = 'Retained last-known storage values, not a current reading.';
|
||||
|
||||
/** The reading the capacity chart shows before any history is stored. */
|
||||
export function getStoragePoolDrawerCurrentMetrics(
|
||||
record: StorageRecord,
|
||||
): Record<string, number | undefined> {
|
||||
if (record.freshness === 'stale') return {};
|
||||
return { usage: getUsagePercent(record) ?? undefined };
|
||||
}
|
||||
|
||||
/** A stale pool's retained usage is shown as last known, not as live. */
|
||||
export function getStoragePoolDrawerDeferredMetrics(
|
||||
record: StorageRecord,
|
||||
): Record<string, GuestDrawerHistoryDeferredMetric> | undefined {
|
||||
if (record.freshness !== 'stale') return undefined;
|
||||
const usage = getUsagePercent(record);
|
||||
return { usage: { lastKnownValue: usage ?? undefined, message: STALE_POOL_MESSAGE } };
|
||||
}
|
||||
|
||||
const titleize = (value: string): string =>
|
||||
value
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
const getZfsStateTone = (state: string): DetailValueTone => {
|
||||
const normalized = (state || '').trim().toUpperCase();
|
||||
if (!normalized || normalized === 'ONLINE') return 'default';
|
||||
if (normalized === 'DEGRADED') return 'warning';
|
||||
return 'danger';
|
||||
};
|
||||
|
||||
const isLiftedTone = (tone: DetailValueTone | undefined): boolean =>
|
||||
tone === 'warning' || tone === 'danger';
|
||||
|
||||
const plural = (count: number, singular: string, pluralLabel = `${singular}s`): string =>
|
||||
`${count.toLocaleString()} ${count === 1 ? singular : pluralLabel}`;
|
||||
|
||||
/** The pool's own ZFS state, scan and error counters. */
|
||||
const buildZfsPoolSection = (zfs: StoragePoolDetailZfsSummary): DetailSection => ({
|
||||
label: 'ZFS pool',
|
||||
rows: compactDetailRows([
|
||||
// The pool's State in front already judges this word; here it is a fact.
|
||||
makeDetailRow('State', zfs.state),
|
||||
makeDetailRow('Scan', zfs.scan, {
|
||||
wrap: true,
|
||||
tone: zfs.scanErrors > 0 ? 'danger' : 'default',
|
||||
}),
|
||||
makeDetailRow('Errors', zfs.errorSummary, { tone: 'danger', wrap: true }),
|
||||
]),
|
||||
});
|
||||
|
||||
const getZfsDeviceValue = (device: StoragePoolDetailZfsSummary['devices'][number]): string =>
|
||||
[device.type, device.state, device.errorSummary, device.message].filter(Boolean).join(' · ');
|
||||
|
||||
const buildZfsDevicesSection = (zfs: StoragePoolDetailZfsSummary): DetailSection => ({
|
||||
label: `ZFS devices (${zfs.devices.length})`,
|
||||
rows: compactDetailRows(
|
||||
zfs.devices.map((device) =>
|
||||
// Device names are paths and by-id names operators copy: they wrap.
|
||||
makeDetailRow(device.name, getZfsDeviceValue(device) || 'Reported', {
|
||||
layout: 'stacked',
|
||||
wrap: true,
|
||||
tone: device.errorSummary ? 'danger' : getZfsStateTone(device.state),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* The pool's content cards: its ZFS state and its device tree. Rows that say
|
||||
* something is wrong are lifted into Worth checking instead of repeating here;
|
||||
* the device list stays whole so a failing member is read in its place.
|
||||
*/
|
||||
export function buildStoragePoolDrawerZfsSections(
|
||||
zfs: StoragePoolDetailZfsSummary | null,
|
||||
): DetailSection[] {
|
||||
if (!zfs) return [];
|
||||
const pool = buildZfsPoolSection(zfs);
|
||||
return compactDetailSections([
|
||||
{ ...pool, rows: pool.rows.filter((row) => !isLiftedTone(row.tone)) },
|
||||
zfs.devices.length > 0 ? buildZfsDevicesSection(zfs) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** A linked disk's facts in one line, for its row's value and hover text. */
|
||||
export function getStoragePoolLinkedDiskSummary(disk: StoragePoolDetailLinkedDisk): string {
|
||||
return [
|
||||
disk.model,
|
||||
disk.role,
|
||||
disk.sizeLabel,
|
||||
disk.state,
|
||||
disk.ioLabel,
|
||||
disk.spunDown ? 'spun down' : '',
|
||||
disk.errorCount > 0 ? `${disk.errorCount.toLocaleString()} errors` : '',
|
||||
disk.temperature > 0 ? formatTemperature(disk.temperature) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export interface StoragePoolDrawerExceptionInput {
|
||||
record: StorageRecord;
|
||||
zfsSummary: StoragePoolDetailZfsSummary | null;
|
||||
linkedDisks: StoragePoolDetailLinkedDisk[];
|
||||
getDiskHeat: (disk: StoragePoolDetailLinkedDisk) => StoragePoolLinkedDiskHeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Worth checking" for a pool: a state that is not healthy, reduced
|
||||
* protection, ZFS pool and scrub errors, devices that are not online, and
|
||||
* linked disks with errors or running hot under the disk heat policy.
|
||||
*/
|
||||
export function buildStoragePoolDrawerExceptionRows(
|
||||
input: StoragePoolDrawerExceptionInput,
|
||||
): DetailRow[] {
|
||||
const { record, zfsSummary } = input;
|
||||
const rows: Array<DetailRow | null> = [];
|
||||
|
||||
const stateTone = getStoragePoolStateTone(record);
|
||||
if (stateTone === 'danger' || stateTone === 'warning') {
|
||||
// The State cell's word, then the reason its hover title gives.
|
||||
const label = getStoragePoolStateLabel(record);
|
||||
const reason = getStoragePoolStateTitle(record);
|
||||
rows.push(
|
||||
makeDetailRow('State', reason && reason !== label ? `${label}: ${reason}` : label, {
|
||||
tone: stateTone,
|
||||
wrap: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (record.protectionReduced) {
|
||||
rows.push(
|
||||
makeDetailRow(
|
||||
'Protection',
|
||||
record.protectionSummary?.trim() || record.protectionLabel?.trim() || 'Reduced',
|
||||
{ tone: 'danger', wrap: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (zfsSummary) {
|
||||
rows.push(...buildZfsPoolSection(zfsSummary).rows.filter((row) => isLiftedTone(row.tone)));
|
||||
for (const device of zfsSummary.devices) {
|
||||
const tone = device.errorSummary ? 'danger' : getZfsStateTone(device.state);
|
||||
if (tone === 'default') continue;
|
||||
const problem = [
|
||||
device.state ? titleize(device.state) : '',
|
||||
device.errorSummary,
|
||||
device.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
rows.push(makeDetailRow('Device', `${device.name}: ${problem}`, { tone, wrap: true }));
|
||||
}
|
||||
}
|
||||
|
||||
for (const disk of input.linkedDisks) {
|
||||
if (disk.errorCount > 0) {
|
||||
rows.push(
|
||||
makeDetailRow('Disk', `${disk.devPath}: ${plural(disk.errorCount, 'error')}`, {
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
}),
|
||||
);
|
||||
} else if (disk.hasIssue) {
|
||||
rows.push(
|
||||
makeDetailRow('Disk', `${disk.devPath}: reallocated sectors`, {
|
||||
tone: 'warning',
|
||||
wrap: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// A disk runs hot from its alert trigger (the disk heat policy); the
|
||||
// band below it is a colour, not heat. A retained reading is not hot now.
|
||||
if (
|
||||
disk.temperature > 0 &&
|
||||
!disk.temperatureLastKnownTitle &&
|
||||
input.getDiskHeat(disk) === 'critical'
|
||||
) {
|
||||
rows.push(
|
||||
makeDetailRow(
|
||||
'Disk temperature',
|
||||
`${disk.devPath}: ${formatTemperature(disk.temperature)}`,
|
||||
{
|
||||
tone: 'danger',
|
||||
wrap: true,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return compactDetailRows(rows);
|
||||
}
|
||||
|
||||
export interface StoragePoolDrawerDetailInput {
|
||||
configRows: StoragePoolDetailConfigRow[];
|
||||
topologyRows: StoragePoolDetailTopologyRow[];
|
||||
zfsSummary: StoragePoolDetailZfsSummary | null;
|
||||
}
|
||||
|
||||
const getDatasetValue = (dataset: StoragePoolDetailZfsSummary['datasets'][number]): string =>
|
||||
[
|
||||
`Used ${dataset.usedLabel}`,
|
||||
`Available ${dataset.availableLabel}`,
|
||||
`Referenced ${dataset.referencedLabel}`,
|
||||
dataset.mountpoint || (dataset.type === 'volume' ? 'zvol' : ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
/** The long tail kept in the closed Details disclosure. */
|
||||
export function buildStoragePoolDrawerDetailSections(
|
||||
input: StoragePoolDrawerDetailInput,
|
||||
): DetailSection[] {
|
||||
const datasets = input.zfsSummary?.datasets ?? [];
|
||||
return compactDetailSections([
|
||||
{
|
||||
label: 'Configuration',
|
||||
rows: compactDetailRows(
|
||||
input.configRows.map((row) => makeDetailRow(row.label, row.value, { wrap: true })),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Topology',
|
||||
rows: compactDetailRows(
|
||||
input.topologyRows.map((row) => makeDetailRow(row.label, row.value, { wrap: true })),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: `Datasets (${datasets.length})`,
|
||||
testId: 'storage-pool-datasets',
|
||||
rows: compactDetailRows(
|
||||
datasets.map((dataset) =>
|
||||
// Dataset names and mountpoints are copied, so they wrap.
|
||||
makeDetailRow(dataset.name, getDatasetValue(dataset), {
|
||||
layout: 'stacked',
|
||||
wrap: true,
|
||||
}),
|
||||
),
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const isPlaceholder = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
return !trimmed || trimmed === '—' || trimmed === '-' || trimmed === 'unassigned';
|
||||
};
|
||||
|
||||
/** The short facts strip under the charts. */
|
||||
export function getStoragePoolDrawerFacts(record: StorageRecord): string[] {
|
||||
const total = observed(record.capacity.totalBytes);
|
||||
const used = observed(record.capacity.usedBytes);
|
||||
// A stale pool states its size only, not a used amount that reads as live.
|
||||
const capacity =
|
||||
total !== null && total > 0
|
||||
? used !== null && record.freshness !== 'stale'
|
||||
? `${formatBytes(used)} of ${formatBytes(total)} used`
|
||||
: `${formatBytes(total)} total`
|
||||
: '';
|
||||
const host = getStorageRecordHostLabel(record);
|
||||
const content = getStorageRecordContent(record);
|
||||
return [
|
||||
getStorageRecordTopologyLabel(record),
|
||||
capacity,
|
||||
isPlaceholder(host) ? '' : `On ${host}`,
|
||||
content
|
||||
? `Holds ${content
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.join(', ')}`
|
||||
: '',
|
||||
getStorageRecordShared(record) === true ? 'Shared' : '',
|
||||
].filter((fact) => !isPlaceholder(fact));
|
||||
}
|
||||
@@ -1306,14 +1306,14 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(storagePoolDetailSource).toContain('StatusDot');
|
||||
expect(storagePoolDetailSource).toContain('getLinkedDiskHealthDotVariant');
|
||||
expect(storagePoolDetailSource).toContain('getLinkedDiskTemperatureTextClass');
|
||||
expect(storagePoolDetailSource).toContain('getZfsScanTextClass');
|
||||
expect(storagePoolDetailSource).toContain('getZfsErrorTextClass');
|
||||
expect(storagePoolDetailSource).toContain('StorageDetailKeyValueRow');
|
||||
expect(storagePoolDetailSource).toContain('STORAGE_DETAIL_CARD_CLASS');
|
||||
// The pool Overview is built like the guest and node drawers: alerts,
|
||||
// Worth checking, drawer charts, then one closed Details of sections.
|
||||
expect(storagePoolDetailSource).toContain('useStoragePoolDetailModel');
|
||||
expect(storagePoolDetailSource).toContain('STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS');
|
||||
expect(storagePoolDetailSource).toContain('FormSelect');
|
||||
expect(storagePoolDetailSource).toContain('label="Capacity history range"');
|
||||
expect(storagePoolDetailSource).toContain('DrawerAttentionSection');
|
||||
expect(storagePoolDetailSource).toContain('DrawerExceptionList');
|
||||
expect(storagePoolDetailSource).toContain('GuestDrawerHistory');
|
||||
expect(storagePoolDetailSource).toContain('TechnicalDetailsDisclosure');
|
||||
expect(storagePoolDetailSource).toContain('DetailSectionTable');
|
||||
expect(storagePoolDetailSource).not.toContain('<select');
|
||||
expect(storagePoolDetailSource).not.toContain('const ConfigRow: Component');
|
||||
expect(storagePoolDetailSource).not.toContain('const chartResourceType = createMemo');
|
||||
@@ -1637,20 +1637,14 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(diskDetailPresentationSource).toContain('export const DISK_DETAIL_LIVE_CHARTS');
|
||||
expect(storageDetailPresentationSource).toContain('export const STORAGE_DETAIL_CARD_CLASS');
|
||||
expect(storageDetailPresentationSource).not.toContain('STORAGE_DETAIL_SELECT_CLASS');
|
||||
expect(storagePoolDetailSource).toContain('filterSelectClass');
|
||||
expect(storageDetailPresentationSource).toContain('export const STORAGE_DETAIL_EMPTY_CLASS');
|
||||
expect(storageDetailPresentationSource).toContain('export const STORAGE_DETAIL_ROW_CLASS');
|
||||
expect(storageDetailPresentationSource).toContain(
|
||||
'export const STORAGE_DISK_DETAIL_HEADER_CLASS',
|
||||
);
|
||||
expect(storageDetailPresentationSource).toContain(
|
||||
'export const STORAGE_DETAIL_FULL_WIDTH_ROW_CLASS',
|
||||
);
|
||||
expect(storageDetailKeyValueRowSource).toContain('InfoCardKeyValueRow');
|
||||
expect(storageDetailKeyValueRowSource).toContain('desktopAt="sm"');
|
||||
expect(storageDetailMetricCardSource).toContain('STORAGE_DETAIL_CARD_CLASS');
|
||||
expect(storagePoolDetailSource).toContain('<tr class={STORAGE_DETAIL_ROW_CLASS}>');
|
||||
expect(storagePoolDetailSource).not.toContain('border-t border-border');
|
||||
expect(storagePoolDetailSource).not.toContain('<tr class=');
|
||||
expect(storagePoolDetailSource).not.toContain('col-span-2');
|
||||
expect(storagePoolDetailSource).not.toContain('text-base-content truncate flex-1');
|
||||
expect(zfsPresentationSource).toContain('export const getZfsDeviceBlockClass');
|
||||
|
||||
Reference in New Issue
Block a user