mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Canonicalize inline detail sections
This commit is contained in:
parent
e27b981701
commit
e6e323c342
19 changed files with 716 additions and 333 deletions
|
|
@ -167,6 +167,8 @@ work extends shared components instead of creating new local variants.
|
|||
138. `frontend-modern/src/components/shared/buttonModel.ts`
|
||||
139. `frontend-modern/src/components/shared/Button.test.tsx`
|
||||
140. `frontend-modern/src/components/shared/CopyableCodeRow.tsx`
|
||||
141. `frontend-modern/src/components/shared/DetailSectionTable.tsx`
|
||||
142. `frontend-modern/src/components/shared/detailSectionModel.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
|
|
@ -480,6 +482,12 @@ not a replacement status card, CTA band, or page-local nested card.
|
|||
copyable command/path/value rows must use `CopyableCodeRow` instead of
|
||||
recreating local copy icons, copied-state checks, disabled empty-value
|
||||
handling, or `font-mono` code-row shells.
|
||||
Inline detail content belongs to the shared detail-section primitive family.
|
||||
Feature surfaces may own the platform-specific rows, section labels, and
|
||||
source model, but section row shaping, empty-row compaction, value tone
|
||||
classes, table rendering, and inline close-action chrome must come from
|
||||
`detailSectionModel.ts`, `DetailSectionTable`, and `InlineDetailPanel`
|
||||
instead of local `DetailField` grids or provider-named reusable primitives.
|
||||
Framed product table surfaces must consume the shared `TableCard` frame and
|
||||
`TableCardHeader` title/action band instead of composing page-local `Card`
|
||||
border, background, overflow, or table-title chrome. Feature owners may own
|
||||
|
|
@ -2537,6 +2545,13 @@ owns the row/cell/content shell and row-click containment for platform,
|
|||
workload, and infrastructure inline drawers; callers may pass row-specific
|
||||
`data-*` attributes, colspan, and content classes, but they must not recreate
|
||||
the surface-alt detail row shell locally.
|
||||
Inline detail section content is registry-backed separately from the row shell.
|
||||
`DetailSectionTable`, `InlineDetailPanel`, and `detailSectionModel.ts` own
|
||||
detail row compaction, section-table rendering, value-tone classes, and the
|
||||
inline close action for platform alert/activity/protection/service detail
|
||||
panels; consumers may own the platform-specific section data, but they must not
|
||||
recreate local `DetailField` grids or route platform-neutral detail tables
|
||||
through a provider-named primitive.
|
||||
Platform row-detail disclosure controls are also registry-backed templates.
|
||||
`frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
|
||||
owns `PlatformResourceDetailToggleButton`, which composes
|
||||
|
|
|
|||
|
|
@ -252,6 +252,13 @@ affordance itself must compose
|
|||
`PlatformResourceDetailTableRow.tsx` contract. Future platform tables must not
|
||||
add page-local chevron buttons, bespoke `aria-expanded` handling, or local
|
||||
event-propagation variants for row detail expansion.
|
||||
Inline detail section content follows the same ownership split.
|
||||
Unified-resource consumers own the source-specific section rows, labels,
|
||||
resource identities, and severity/status semantics, but repeated section-row
|
||||
compaction, table rendering, value-tone classes, and inline close-action chrome
|
||||
must compose the frontend-primitives-owned `DetailSectionTable`,
|
||||
`InlineDetailPanel`, and `detailSectionModel.ts` primitives instead of
|
||||
recreating local `DetailField` grids or provider-named neutral detail tables.
|
||||
The split also applies to web-interface launch affordances. Unified-resource
|
||||
tables own whether a row has a saved, inferred, or source-native web-interface
|
||||
URL and how that URL is derived, but the visible launch affordance belongs on
|
||||
|
|
|
|||
|
|
@ -114,6 +114,54 @@
|
|||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inline-detail-section-panel-shell",
|
||||
"category": "row-detail",
|
||||
"summary": "Inline platform detail content must compose DetailSectionTable or InlineDetailPanel for shared detail rows, value tones, section tables, and close actions instead of recreating local field grids or provider-named detail primitives.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/DetailSectionTable.tsx",
|
||||
"export": "DetailSection"
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx" },
|
||||
{ "path": "src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts" },
|
||||
{ "path": "src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts" },
|
||||
{ "path": "src/features/docker/DockerAlertsTable.tsx" },
|
||||
{ "path": "src/features/kubernetes/KubernetesAlertsTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASAlertsTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASProtectionTable.tsx" },
|
||||
{ "path": "src/features/truenas/TrueNASServicesTable.tsx" },
|
||||
{ "path": "src/features/vmware/VsphereActivityTable.tsx" },
|
||||
{ "path": "src/features/vmware/VsphereAlertsTable.tsx" }
|
||||
],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
|
||||
"patterns": ["makeTrueNASDetailRow", "TrueNASDetailSection", "trueNASDetailTableModel"]
|
||||
},
|
||||
{
|
||||
"path": "src/features/docker/DockerAlertsTable.tsx",
|
||||
"patterns": ["const DetailField", "XIcon class=\"h-4 w-4\""]
|
||||
},
|
||||
{
|
||||
"path": "src/features/kubernetes/KubernetesAlertsTable.tsx",
|
||||
"patterns": ["const DetailField", "XIcon class=\"h-4 w-4\""]
|
||||
},
|
||||
{
|
||||
"path": "src/features/vmware/VsphereActivityTable.tsx",
|
||||
"patterns": ["const DetailField", "XIcon class=\"h-4 w-4\""]
|
||||
},
|
||||
{
|
||||
"path": "src/features/vmware/VsphereAlertsTable.tsx",
|
||||
"patterns": ["const DetailField", "XIcon class=\"h-4 w-4\""]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table-card-frame-shell",
|
||||
"category": "table-card",
|
||||
|
|
@ -1248,6 +1296,55 @@
|
|||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inline-detail-local-field-grid-shell",
|
||||
"category": "row-detail",
|
||||
"summary": "Inline platform detail content must not recreate local DetailField grids; use DetailSectionTable or InlineDetailPanel.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/DetailSectionTable.tsx",
|
||||
"export": "InlineDetailPanel"
|
||||
},
|
||||
"scopes": [
|
||||
"src/components/Infrastructure",
|
||||
"src/features/docker",
|
||||
"src/features/kubernetes",
|
||||
"src/features/truenas",
|
||||
"src/features/vmware"
|
||||
],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": [
|
||||
"const DetailField",
|
||||
"font-semibold uppercase tracking-wide text-muted",
|
||||
"grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
],
|
||||
"legacyReason": "Retired migration debt. Inline detail field grids belong to DetailSectionTable / InlineDetailPanel so detail sections, value tones, truncation, and close actions stay canonical.",
|
||||
"allowedPaths": [],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "inline-detail-provider-named-primitive-import",
|
||||
"category": "row-detail",
|
||||
"summary": "Platform-neutral detail section tables must not import the retired provider-named TrueNAS detail primitive.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/DetailSectionTable.tsx",
|
||||
"export": "DetailSectionTable"
|
||||
},
|
||||
"scopes": ["src/components", "src/features"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"allPatterns": ["TrueNASDetailTable"],
|
||||
"legacyReason": "Retired migration debt. Provider-specific names must not own platform-neutral detail tables; use DetailSectionTable / detailSectionModel.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "button-secondary-settings-action-local-shell",
|
||||
"category": "action-button",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import { ResourceOperatorStateSection } from './ResourceOperatorStateSection';
|
|||
import { MaintenanceVerificationSection } from './MaintenanceVerificationSection';
|
||||
import { InlineResourceSummaryTables, ResourceSummaryCards } from './ResourceDetailSummary';
|
||||
import { ResourceInvestigationContextTables } from './ResourceInvestigationContextTables';
|
||||
import { TrueNASDetailSectionTable } from './TrueNASDetailTable';
|
||||
import { DetailSectionTable } from '@/components/shared/DetailSectionTable';
|
||||
import {
|
||||
RESOURCE_CHANGE_KIND_ORDER,
|
||||
RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER,
|
||||
|
|
@ -82,7 +82,7 @@ const TrueNASDetailsDisclosure: Component<{
|
|||
contentClass={props.contentClass ?? 'mt-3 space-y-3'}
|
||||
dataTestId="resource-truenas-details-section"
|
||||
>
|
||||
<TrueNASDetailSectionTable sections={props.drawer.trueNASDetailSections()} />
|
||||
<DetailSectionTable sections={props.drawer.trueNASDetailSections()} />
|
||||
</SupportDisclosure>
|
||||
);
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ const KubernetesDetailsDisclosure: Component<{
|
|||
contentClass={props.contentClass ?? 'mt-3 space-y-3'}
|
||||
dataTestId="resource-kubernetes-details-section"
|
||||
>
|
||||
<TrueNASDetailSectionTable sections={props.drawer.kubernetesDetailSections()} />
|
||||
<DetailSectionTable sections={props.drawer.kubernetesDetailSections()} />
|
||||
</SupportDisclosure>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import type { Resource, ResourceKubernetesPodContainerStatus } from '@/types/resource';
|
||||
// Reuses the platform-neutral section/row shape (and the section table
|
||||
// renderer) introduced for the TrueNAS drawer details.
|
||||
import {
|
||||
compactTrueNASDetailRows as compactRows,
|
||||
compactTrueNASDetailSections as compactSections,
|
||||
makeTrueNASDetailRow as makeRow,
|
||||
type TrueNASDetailSection,
|
||||
} from './trueNASDetailTableModel';
|
||||
compactDetailRows as compactRows,
|
||||
compactDetailSections as compactSections,
|
||||
makeDetailRow as makeRow,
|
||||
type DetailSection,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
|
||||
export type ResourceDetailDrawerKubernetesSection = TrueNASDetailSection;
|
||||
export type ResourceDetailDrawerKubernetesSection = DetailSection;
|
||||
|
||||
const asString = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
|
|
@ -27,11 +25,7 @@ const formatBytes = (bytes?: number): string | null => {
|
|||
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatNodeBudget = (
|
||||
cores?: number,
|
||||
memoryBytes?: number,
|
||||
pods?: number,
|
||||
): string | null => {
|
||||
const formatNodeBudget = (cores?: number, memoryBytes?: number, pods?: number): string | null => {
|
||||
const parts: string[] = [];
|
||||
if (typeof cores === 'number' && Number.isFinite(cores) && cores > 0) {
|
||||
parts.push(`${cores} cores`);
|
||||
|
|
@ -82,7 +76,7 @@ export const buildKubernetesDetailSections = (
|
|||
const k = resource.kubernetes;
|
||||
if (!k) return [];
|
||||
|
||||
const sections: Array<TrueNASDetailSection | null> = [];
|
||||
const sections: Array<DetailSection | null> = [];
|
||||
|
||||
if (resource.type === 'pod') {
|
||||
const podRows = compactRows([makeRow('QoS class', k.qosClass)]);
|
||||
|
|
@ -149,10 +143,9 @@ export const buildKubernetesDetailsSummary = (resource: Resource): string | null
|
|||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
if (isKubernetesNodeResource(resource)) {
|
||||
const parts = [
|
||||
asString(k.osImage),
|
||||
k.unschedulable === true ? 'Cordoned' : null,
|
||||
].filter(Boolean);
|
||||
const parts = [asString(k.osImage), k.unschedulable === true ? 'Cordoned' : null].filter(
|
||||
Boolean,
|
||||
);
|
||||
return parts.length > 0 ? parts.join(' · ') : null;
|
||||
}
|
||||
if (resource.type === 'k8s-cluster') {
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ import type {
|
|||
ResourceTrueNASVMMeta,
|
||||
} from '@/types/resource';
|
||||
import {
|
||||
compactTrueNASDetailRows,
|
||||
compactTrueNASDetailSections,
|
||||
makeTrueNASDetailRow,
|
||||
type TrueNASDetailRow,
|
||||
type TrueNASDetailSection,
|
||||
type TrueNASDetailTone,
|
||||
} from './trueNASDetailTableModel';
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/detailSectionModel';
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRowTone = TrueNASDetailTone;
|
||||
export type ResourceDetailDrawerTrueNASRowTone = DetailValueTone;
|
||||
|
||||
export type ResourceDetailDrawerTrueNASRow = TrueNASDetailRow;
|
||||
export type ResourceDetailDrawerTrueNASRow = DetailRow;
|
||||
|
||||
export type ResourceDetailDrawerTrueNASSection = TrueNASDetailSection;
|
||||
export type ResourceDetailDrawerTrueNASSection = DetailSection;
|
||||
|
||||
const asString = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
|
|
@ -105,9 +105,9 @@ const yesNoValue = (value?: boolean): string | null => {
|
|||
return value ? 'Yes' : 'No';
|
||||
};
|
||||
|
||||
const row = makeTrueNASDetailRow;
|
||||
const compactRows = compactTrueNASDetailRows;
|
||||
const compactSections = compactTrueNASDetailSections;
|
||||
const row = makeDetailRow;
|
||||
const compactRows = compactDetailRows;
|
||||
const compactSections = compactDetailSections;
|
||||
|
||||
const isTrueNASScopedResource = (resource: Resource): boolean =>
|
||||
resource.platformType === 'truenas' ||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
export type TrueNASDetailTone = 'default' | 'accent' | 'success' | 'warning' | 'danger' | 'muted';
|
||||
|
||||
export type TrueNASDetailRow = {
|
||||
label: string;
|
||||
value: string;
|
||||
title?: string;
|
||||
tone?: TrueNASDetailTone;
|
||||
};
|
||||
|
||||
export type TrueNASDetailSection = {
|
||||
label: string;
|
||||
rows: TrueNASDetailRow[];
|
||||
};
|
||||
|
||||
export const makeTrueNASDetailRow = (
|
||||
label: string,
|
||||
value?: string | null,
|
||||
options: Pick<TrueNASDetailRow, 'title' | 'tone'> = {},
|
||||
): TrueNASDetailRow | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === '-') return null;
|
||||
return { label, value: trimmed, ...options };
|
||||
};
|
||||
|
||||
export const compactTrueNASDetailRows = (
|
||||
rows: Array<TrueNASDetailRow | null>,
|
||||
): TrueNASDetailRow[] => rows.filter((row): row is TrueNASDetailRow => Boolean(row));
|
||||
|
||||
export const compactTrueNASDetailSections = (
|
||||
sections: Array<TrueNASDetailSection | null>,
|
||||
): TrueNASDetailSection[] =>
|
||||
sections.filter((section): section is TrueNASDetailSection =>
|
||||
Boolean(section && section.rows.length > 0),
|
||||
);
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { For, type Component } from 'solid-js';
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
import type { TrueNASDetailSection, TrueNASDetailTone } from './trueNASDetailTableModel';
|
||||
import { For, type Component, type JSX } from 'solid-js';
|
||||
import { Button } from './Button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from './Table';
|
||||
import type { DetailSection, DetailValueTone } from './detailSectionModel';
|
||||
|
||||
export {
|
||||
compactTrueNASDetailRows,
|
||||
compactTrueNASDetailSections,
|
||||
makeTrueNASDetailRow,
|
||||
type TrueNASDetailRow,
|
||||
type TrueNASDetailSection,
|
||||
type TrueNASDetailTone,
|
||||
} from './trueNASDetailTableModel';
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from './detailSectionModel';
|
||||
|
||||
const detailValueToneClass = (tone: TrueNASDetailTone | undefined): string => {
|
||||
const detailValueToneClass = (tone: DetailValueTone | undefined): string => {
|
||||
if (tone === 'accent') return 'text-cyan-700 dark:text-cyan-300';
|
||||
if (tone === 'success') return 'text-emerald-700 dark:text-emerald-300';
|
||||
if (tone === 'warning') return 'text-amber-700 dark:text-amber-300';
|
||||
|
|
@ -20,14 +21,8 @@ const detailValueToneClass = (tone: TrueNASDetailTone | undefined): string => {
|
|||
return 'text-base-content';
|
||||
};
|
||||
|
||||
const truenasDetailAttribute = (
|
||||
detailKind: 'alert' | 'protection' | 'service' | undefined,
|
||||
detailFor: string,
|
||||
): Record<string, string> =>
|
||||
detailKind ? { [`data-truenas-${detailKind}-detail-for`]: detailFor } : {};
|
||||
|
||||
export const TrueNASDetailSectionTable: Component<{
|
||||
sections: TrueNASDetailSection[];
|
||||
export const DetailSectionTable: Component<{
|
||||
sections: DetailSection[];
|
||||
class?: string;
|
||||
}> = (props) => (
|
||||
<div class={props.class ?? 'overflow-hidden rounded border border-border bg-surface'}>
|
||||
|
|
@ -69,36 +64,40 @@ export const TrueNASDetailSectionTable: Component<{
|
|||
</div>
|
||||
);
|
||||
|
||||
export const TrueNASInlineDetailTable: Component<{
|
||||
export const InlineDetailPanel: Component<{
|
||||
testId: string;
|
||||
detailFor: string;
|
||||
detailKind?: 'alert' | 'protection' | 'service';
|
||||
title: string;
|
||||
summary: string;
|
||||
sections: TrueNASDetailSection[];
|
||||
title: JSX.Element;
|
||||
summary?: JSX.Element;
|
||||
sections: DetailSection[];
|
||||
onClose: () => void;
|
||||
class?: string;
|
||||
tableClass?: string;
|
||||
detailAttributes?: Record<string, string>;
|
||||
}> = (props) => (
|
||||
<div
|
||||
class="space-y-3"
|
||||
class={props.class ?? 'space-y-3'}
|
||||
{...(props.detailAttributes ?? {})}
|
||||
data-testid={props.testId}
|
||||
data-truenas-inline-detail-for={props.detailFor}
|
||||
{...truenasDetailAttribute(props.detailKind, props.detailFor)}
|
||||
data-inline-detail-for={props.detailFor}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content">
|
||||
{props.title}
|
||||
</div>
|
||||
<div class="mt-1 text-[10px] text-muted">{props.summary}</div>
|
||||
{props.summary ? <div class="mt-1 text-[10px] text-muted">{props.summary}</div> : null}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
class="bg-surface text-[10px] hover:bg-base"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<TrueNASDetailSectionTable sections={props.sections} />
|
||||
<DetailSectionTable sections={props.sections} class={props.tableClass} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -9,6 +9,8 @@ import aiModelPickerSource from '@/components/shared/AIModelPicker.tsx?raw';
|
|||
import buttonSource from '@/components/shared/Button.tsx?raw';
|
||||
import buttonModelSource from '@/components/shared/buttonModel.ts?raw';
|
||||
import copyableCodeRowSource from '@/components/shared/CopyableCodeRow.tsx?raw';
|
||||
import detailSectionTableSource from '@/components/shared/DetailSectionTable.tsx?raw';
|
||||
import detailSectionModelSource from '@/components/shared/detailSectionModel.ts?raw';
|
||||
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
|
||||
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
|
||||
import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw';
|
||||
|
|
@ -174,6 +176,8 @@ import alertResourceTableDesktopSource from '@/components/Alerts/AlertResourceTa
|
|||
import aiCostDashboardSource from '@/components/AI/AICostDashboard.tsx?raw';
|
||||
import resourceDetailSummarySource from '@/components/Infrastructure/ResourceDetailSummary.tsx?raw';
|
||||
import resourceDetailDrawerDebugTabSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
|
||||
import resourceDetailDrawerKubernetesModelSource from '@/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts?raw';
|
||||
import resourceDetailDrawerTrueNASModelSource from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts?raw';
|
||||
import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw';
|
||||
import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw';
|
||||
import apiTokenManagerSource from '@/components/Settings/APITokenManager.tsx?raw';
|
||||
|
|
@ -207,8 +211,14 @@ import useSavedViewsSource from '@/components/shared/FilterBar/useSavedViews.ts?
|
|||
import storagePageControlsSource from '@/components/Storage/StoragePageControls.tsx?raw';
|
||||
import orgSwitcherSource from '@/components/OrgSwitcher.tsx?raw';
|
||||
import resourceDetailDrawerOverviewTabSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
import dockerAlertsTableSource from '@/features/docker/DockerAlertsTable.tsx?raw';
|
||||
import kubernetesAlertsTableSource from '@/features/kubernetes/KubernetesAlertsTable.tsx?raw';
|
||||
import proxmoxBackupsTableSharedSource from '@/features/proxmox/proxmoxBackupsTableShared.tsx?raw';
|
||||
import truenasAlertsTableSource from '@/features/truenas/TrueNASAlertsTable.tsx?raw';
|
||||
import truenasServicesTableSource from '@/features/truenas/TrueNASServicesTable.tsx?raw';
|
||||
import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw';
|
||||
import vsphereActivityTableSource from '@/features/vmware/VsphereActivityTable.tsx?raw';
|
||||
import vsphereAlertsTableSource from '@/features/vmware/VsphereAlertsTable.tsx?raw';
|
||||
|
||||
const sharedSources = import.meta.glob(['./*.tsx', './cards/*.tsx', './responsive/*.tsx'], {
|
||||
query: '?raw',
|
||||
|
|
@ -2546,6 +2556,111 @@ describe('shared primitive guardrails', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps inline detail section content on the shared detail primitive', () => {
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
requiredConsumers?: Array<{ path?: string }>;
|
||||
forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>;
|
||||
}>;
|
||||
patternGuards?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
allPatterns?: string[];
|
||||
allowedPaths?: string[];
|
||||
scopes?: string[];
|
||||
}>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find(
|
||||
(rule) => rule.id === 'inline-detail-section-panel-shell',
|
||||
);
|
||||
const fieldGridGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'inline-detail-local-field-grid-shell',
|
||||
);
|
||||
const providerNamedGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'inline-detail-provider-named-primitive-import',
|
||||
);
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/DetailSectionTable.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('DetailSection');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
|
||||
'src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts',
|
||||
'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
|
||||
'src/features/docker/DockerAlertsTable.tsx',
|
||||
'src/features/kubernetes/KubernetesAlertsTable.tsx',
|
||||
'src/features/truenas/TrueNASAlertsTable.tsx',
|
||||
'src/features/truenas/TrueNASProtectionTable.tsx',
|
||||
'src/features/truenas/TrueNASServicesTable.tsx',
|
||||
'src/features/vmware/VsphereActivityTable.tsx',
|
||||
'src/features/vmware/VsphereAlertsTable.tsx',
|
||||
]),
|
||||
);
|
||||
expect(registeredRule?.forbiddenPatterns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts',
|
||||
patterns: expect.arrayContaining(['makeTrueNASDetailRow', 'trueNASDetailTableModel']),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(fieldGridGuard?.canonical?.path).toBe('src/components/shared/DetailSectionTable.tsx');
|
||||
expect(fieldGridGuard?.canonical?.export).toBe('InlineDetailPanel');
|
||||
expect(fieldGridGuard?.allPatterns).toEqual([
|
||||
'const DetailField',
|
||||
'font-semibold uppercase tracking-wide text-muted',
|
||||
'grid gap-3 sm:grid-cols-2 lg:grid-cols-3',
|
||||
]);
|
||||
expect(fieldGridGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(providerNamedGuard?.canonical?.path).toBe(
|
||||
'src/components/shared/DetailSectionTable.tsx',
|
||||
);
|
||||
expect(providerNamedGuard?.canonical?.export).toBe('DetailSectionTable');
|
||||
expect(providerNamedGuard?.allPatterns).toEqual(['TrueNASDetailTable']);
|
||||
expect(providerNamedGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
|
||||
expect(detailSectionModelSource).toContain('export type DetailSection');
|
||||
expect(detailSectionModelSource).toContain('makeDetailRow');
|
||||
expect(detailSectionTableSource).toContain('DetailSectionTable');
|
||||
expect(detailSectionTableSource).toContain('InlineDetailPanel');
|
||||
expect(detailSectionTableSource).toContain('variant="outline"');
|
||||
|
||||
for (const source of [
|
||||
resourceDetailDrawerKubernetesModelSource,
|
||||
resourceDetailDrawerTrueNASModelSource,
|
||||
]) {
|
||||
expect(source).toContain('@/components/shared/detailSectionModel');
|
||||
expect(source).toContain('DetailSection');
|
||||
expect(source).not.toContain('trueNASDetailTableModel');
|
||||
expect(source).not.toContain('makeTrueNASDetailRow');
|
||||
}
|
||||
|
||||
expect(resourceDetailDrawerOverviewTabSource).toContain('DetailSectionTable');
|
||||
expect(resourceDetailDrawerOverviewTabSource).not.toContain('TrueNASDetailSectionTable');
|
||||
|
||||
for (const source of [
|
||||
dockerAlertsTableSource,
|
||||
kubernetesAlertsTableSource,
|
||||
truenasAlertsTableSource,
|
||||
truenasProtectionTableSource,
|
||||
truenasServicesTableSource,
|
||||
vsphereActivityTableSource,
|
||||
vsphereAlertsTableSource,
|
||||
]) {
|
||||
expect(source).toContain('InlineDetailPanel');
|
||||
expect(source).toContain('build');
|
||||
expect(source).toContain('DetailSections');
|
||||
expect(source).not.toContain('const DetailField');
|
||||
expect(source).not.toContain('grid gap-3 sm:grid-cols-2 lg:grid-cols-3');
|
||||
expect(source).not.toContain('TrueNASInlineDetailTable');
|
||||
expect(source).not.toContain('TrueNASDetailTable');
|
||||
expect(source).not.toContain('XIcon class="h-4 w-4"');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps help icon on shell, runtime, and model owners', () => {
|
||||
expect(helpIconSource).toContain('useHelpIconState');
|
||||
expect(helpIconSource).not.toContain('getHelpContent(');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { cleanup, render, screen, within } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DetailSectionTable,
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
} from '../DetailSectionTable';
|
||||
import detailSectionTableSource from '../DetailSectionTable.tsx?raw';
|
||||
import detailSectionModelSource from '../detailSectionModel.ts?raw';
|
||||
|
||||
describe('DetailSectionTable', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('keeps detail section row shaping in the shared model', () => {
|
||||
expect(detailSectionModelSource).toContain('export type DetailValueTone');
|
||||
expect(detailSectionModelSource).toContain('makeDetailRow');
|
||||
expect(detailSectionTableSource).toContain('DetailSectionTable');
|
||||
expect(detailSectionTableSource).toContain('InlineDetailPanel');
|
||||
|
||||
expect(
|
||||
compactDetailRows([
|
||||
makeDetailRow('Host', ' tower '),
|
||||
makeDetailRow('Blank', ' '),
|
||||
makeDetailRow('Fallback', '-'),
|
||||
]),
|
||||
).toEqual([{ label: 'Host', value: 'tower' }]);
|
||||
|
||||
expect(
|
||||
compactDetailSections([
|
||||
{ label: 'Runtime', rows: [] },
|
||||
{ label: 'Host', rows: [makeDetailRow('Name', 'tower')!] },
|
||||
]),
|
||||
).toEqual([{ label: 'Host', rows: [{ label: 'Name', value: 'tower' }] }]);
|
||||
});
|
||||
|
||||
it('renders section tables with shared value tone classes', () => {
|
||||
render(() => (
|
||||
<DetailSectionTable
|
||||
sections={[
|
||||
{
|
||||
label: 'Alert',
|
||||
rows: [
|
||||
{ label: 'Severity', value: 'Warning', tone: 'warning' },
|
||||
{ label: 'Resource', value: 'tower', title: 'tower.example.test' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(screen.getByText('Alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Severity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Warning').closest('td')).toHaveClass('text-amber-700');
|
||||
expect(screen.getByText('tower').closest('td')).toHaveAttribute('title', 'tower.example.test');
|
||||
});
|
||||
|
||||
it('renders inline detail panels with the canonical close action', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(() => (
|
||||
<InlineDetailPanel
|
||||
testId="platform-detail"
|
||||
detailFor="resource-1"
|
||||
title="Alert detail"
|
||||
summary="Warning"
|
||||
sections={[{ label: 'Alert', rows: [{ label: 'Severity', value: 'Warning' }] }]}
|
||||
detailAttributes={{ 'data-platform-alert-detail-for': 'resource-1' }}
|
||||
onClose={onClose}
|
||||
/>
|
||||
));
|
||||
|
||||
const panel = screen.getByTestId('platform-detail');
|
||||
expect(panel).toHaveAttribute('data-inline-detail-for', 'resource-1');
|
||||
expect(panel).toHaveAttribute('data-platform-alert-detail-for', 'resource-1');
|
||||
expect(within(panel).getByText('Alert detail')).toBeInTheDocument();
|
||||
expect(within(panel).getAllByText('Warning')).toHaveLength(2);
|
||||
|
||||
within(panel).getByRole('button', { name: 'Close' }).click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
31
frontend-modern/src/components/shared/detailSectionModel.ts
Normal file
31
frontend-modern/src/components/shared/detailSectionModel.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export type DetailValueTone = 'default' | 'accent' | 'success' | 'warning' | 'danger' | 'muted';
|
||||
|
||||
export type DetailRow = {
|
||||
label: string;
|
||||
value: string;
|
||||
title?: string;
|
||||
tone?: DetailValueTone;
|
||||
};
|
||||
|
||||
export type DetailSection = {
|
||||
label: string;
|
||||
rows: DetailRow[];
|
||||
};
|
||||
|
||||
export const makeDetailRow = (
|
||||
label: string,
|
||||
value?: string | null,
|
||||
options: Pick<DetailRow, 'title' | 'tone'> = {},
|
||||
): DetailRow | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === '-') return null;
|
||||
return { label, value: trimmed, ...options };
|
||||
};
|
||||
|
||||
export const compactDetailRows = (rows: Array<DetailRow | null>): DetailRow[] =>
|
||||
rows.filter((row): row is DetailRow => Boolean(row));
|
||||
|
||||
export const compactDetailSections = (sections: Array<DetailSection | null>): DetailSection[] =>
|
||||
sections.filter((section): section is DetailSection =>
|
||||
Boolean(section && section.rows.length > 0),
|
||||
);
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
|
|
@ -141,53 +148,68 @@ const detailDateTime = (value?: string): string => {
|
|||
});
|
||||
};
|
||||
|
||||
const DetailField: Component<{ label: string; value: string | undefined }> = (props) => (
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-wide text-muted">{props.label}</dt>
|
||||
<dd class="mt-1 truncate text-xs text-base-content" title={props.value || '-'}>
|
||||
{props.value || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
type AlertDetailTone = DetailValueTone;
|
||||
type AlertDetailSection = DetailSection;
|
||||
|
||||
const AlertDetail: Component<{ incident: DockerIncidentRow; onClose: () => void }> = (props) => {
|
||||
const docker = () => props.incident.resource.docker;
|
||||
return (
|
||||
<div data-testid="docker-alert-detail" class="space-y-3">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-base-content">Docker alert detail</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted" title={props.incident.summary}>
|
||||
{severityLabel(props.incident.severity)} · {formatCode(props.incident.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailField label="Summary" value={props.incident.summary} />
|
||||
<DetailField label="Signal" value={props.incident.label} />
|
||||
<DetailField label="Severity" value={severityLabel(props.incident.severity)} />
|
||||
<DetailField label="Resource" value={props.incident.resourceName} />
|
||||
<DetailField label="Type" value={formatResourceType(props.incident.resourceType)} />
|
||||
<DetailField label="Host" value={docker()?.hostname} />
|
||||
<DetailField label="Runtime" value={docker()?.runtime} />
|
||||
<DetailField label="Swarm cluster" value={docker()?.swarm?.clusterName} />
|
||||
<DetailField label="Started" value={detailDateTime(props.incident.startedAt)} />
|
||||
<DetailField label="Source" value={props.incident.source} />
|
||||
<DetailField label="Action" value={props.incident.action} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
const alertTone = (severity: DockerIncidentRow['severityBucket']): AlertDetailTone => {
|
||||
if (severity === 'critical') return 'danger';
|
||||
if (severity === 'warning') return 'warning';
|
||||
return 'muted';
|
||||
};
|
||||
|
||||
const buildAlertDetailSections = (incident: DockerIncidentRow): AlertDetailSection[] => {
|
||||
const docker = incident.resource.docker;
|
||||
return compactDetailSections([
|
||||
{
|
||||
label: 'Alert',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Severity', severityLabel(incident.severity), {
|
||||
tone: alertTone(incident.severityBucket),
|
||||
}),
|
||||
detailRow('Summary', incident.summary),
|
||||
detailRow('Signal', incident.label),
|
||||
detailRow('Code', formatCode(incident.code), { title: incident.code }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Affected resource',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Name', incident.resourceName),
|
||||
detailRow('Type', formatResourceType(incident.resourceType)),
|
||||
detailRow('Host', docker?.hostname),
|
||||
detailRow('Runtime', docker?.runtime),
|
||||
detailRow('Swarm cluster', docker?.swarm?.clusterName),
|
||||
detailRow('Resource ID', incident.resourceId),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Source',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Started', detailDateTime(incident.startedAt)),
|
||||
detailRow('Provider', incident.source),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Action',
|
||||
rows: compactDetailRows([detailRow('Recommended', incident.action)]),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const AlertDetail: Component<{ incident: DockerIncidentRow; onClose: () => void }> = (props) => (
|
||||
<InlineDetailPanel
|
||||
testId="docker-alert-detail"
|
||||
detailFor={props.incident.id}
|
||||
title="Docker alert detail"
|
||||
summary={`${severityLabel(props.incident.severity)} · ${formatCode(props.incident.code)}`}
|
||||
sections={buildAlertDetailSections(props.incident)}
|
||||
detailAttributes={{ 'data-docker-alert-detail-for': props.incident.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DockerAlertsTable: Component<{
|
||||
incidents: DockerIncidentRow[];
|
||||
emptyIcon: JSX.Element;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
|
|
@ -156,60 +163,71 @@ const detailDateTime = (value?: string): string => {
|
|||
});
|
||||
};
|
||||
|
||||
const DetailField: Component<{ label: string; value: string | undefined }> = (props) => (
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-wide text-muted">{props.label}</dt>
|
||||
<dd class="mt-1 truncate text-xs text-base-content" title={props.value || '-'}>
|
||||
{props.value || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
type AlertDetailTone = DetailValueTone;
|
||||
type AlertDetailSection = DetailSection;
|
||||
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
const alertTone = (severity: KubernetesIncidentRow['severityBucket']): AlertDetailTone => {
|
||||
if (severity === 'critical') return 'danger';
|
||||
if (severity === 'warning') return 'warning';
|
||||
return 'muted';
|
||||
};
|
||||
|
||||
const buildAlertDetailSections = (incident: KubernetesIncidentRow): AlertDetailSection[] => {
|
||||
const k = incident.resource.kubernetes;
|
||||
const owner = k?.ownerKind && k?.ownerName ? `${k.ownerKind}/${k.ownerName}` : undefined;
|
||||
return compactDetailSections([
|
||||
{
|
||||
label: 'Alert',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Severity', severityLabel(incident.severity), {
|
||||
tone: alertTone(incident.severityBucket),
|
||||
}),
|
||||
detailRow('Summary', incident.summary),
|
||||
detailRow('Signal', incident.label),
|
||||
detailRow('Code', formatCode(incident.code), { title: incident.code }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Affected resource',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Name', incident.resourceName),
|
||||
detailRow('Type', formatResourceType(incident.resourceType)),
|
||||
detailRow('Cluster', k?.clusterName || k?.clusterId),
|
||||
detailRow('Namespace', k?.namespace),
|
||||
detailRow('Node', k?.nodeName),
|
||||
detailRow('Owner', owner),
|
||||
detailRow('Resource ID', incident.resourceId),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Source',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Started', detailDateTime(incident.startedAt)),
|
||||
detailRow('Provider', incident.source),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Action',
|
||||
rows: compactDetailRows([detailRow('Recommended', incident.action)]),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const AlertDetail: Component<{ incident: KubernetesIncidentRow; onClose: () => void }> = (
|
||||
props,
|
||||
) => {
|
||||
const k = () => props.incident.resource.kubernetes;
|
||||
return (
|
||||
<div data-testid="kubernetes-alert-detail" class="space-y-3">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-base-content">Kubernetes alert detail</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted" title={props.incident.summary}>
|
||||
{severityLabel(props.incident.severity)} · {formatCode(props.incident.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailField label="Summary" value={props.incident.summary} />
|
||||
<DetailField label="Signal" value={props.incident.label} />
|
||||
<DetailField label="Severity" value={severityLabel(props.incident.severity)} />
|
||||
<DetailField label="Resource" value={props.incident.resourceName} />
|
||||
<DetailField label="Type" value={formatResourceType(props.incident.resourceType)} />
|
||||
<DetailField label="Cluster" value={k()?.clusterName || k()?.clusterId} />
|
||||
<DetailField label="Namespace" value={k()?.namespace} />
|
||||
<DetailField label="Node" value={k()?.nodeName} />
|
||||
<DetailField
|
||||
label="Owner"
|
||||
value={
|
||||
k()?.ownerKind && k()?.ownerName ? `${k()?.ownerKind}/${k()?.ownerName}` : undefined
|
||||
}
|
||||
/>
|
||||
<DetailField label="Started" value={detailDateTime(props.incident.startedAt)} />
|
||||
<DetailField label="Source" value={props.incident.source} />
|
||||
<DetailField label="Action" value={props.incident.action} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
) => (
|
||||
<InlineDetailPanel
|
||||
testId="kubernetes-alert-detail"
|
||||
detailFor={props.incident.id}
|
||||
title="Kubernetes alert detail"
|
||||
summary={`${severityLabel(props.incident.severity)} · ${formatCode(props.incident.code)}`}
|
||||
sections={buildAlertDetailSections(props.incident)}
|
||||
detailAttributes={{ 'data-kubernetes-alert-detail-for': props.incident.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
export const KubernetesAlertsTable: Component<{
|
||||
incidents: KubernetesIncidentRow[];
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ import {
|
|||
type TrueNASIncidentSeverityFilter,
|
||||
} from './truenasPageModel';
|
||||
import {
|
||||
TrueNASInlineDetailTable,
|
||||
compactTrueNASDetailRows,
|
||||
compactTrueNASDetailSections,
|
||||
makeTrueNASDetailRow,
|
||||
type TrueNASDetailSection,
|
||||
type TrueNASDetailTone,
|
||||
} from '@/components/Infrastructure/TrueNASDetailTable';
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import type { Resource, ResourceType } from '@/types/resource';
|
||||
import { getAlertFilteredEmptyState } from '@/utils/alertOverviewPresentation';
|
||||
|
||||
|
|
@ -115,8 +115,8 @@ const formatStartedAt = (value: string | undefined): string => {
|
|||
});
|
||||
};
|
||||
|
||||
type AlertDetailTone = TrueNASDetailTone;
|
||||
type AlertDetailSection = TrueNASDetailSection;
|
||||
type AlertDetailTone = DetailValueTone;
|
||||
type AlertDetailSection = DetailSection;
|
||||
|
||||
const detailDateTime = (value?: string): string | null => {
|
||||
if (!value) return null;
|
||||
|
|
@ -131,9 +131,7 @@ const detailDateTime = (value?: string): string | null => {
|
|||
});
|
||||
};
|
||||
|
||||
const detailRow = makeTrueNASDetailRow;
|
||||
const compactDetailRows = compactTrueNASDetailRows;
|
||||
const compactDetailSections = compactTrueNASDetailSections;
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
const alertTone = (severity: TrueNASIncidentRow['severityBucket']): AlertDetailTone => {
|
||||
if (severity === 'critical') return 'danger';
|
||||
|
|
@ -182,13 +180,13 @@ const buildAlertDetailSections = (incident: TrueNASIncidentRow): AlertDetailSect
|
|||
const AlertDetailTable: Component<{ incident: TrueNASIncidentRow; onClose: () => void }> = (
|
||||
props,
|
||||
) => (
|
||||
<TrueNASInlineDetailTable
|
||||
<InlineDetailPanel
|
||||
testId="truenas-alert-detail"
|
||||
detailFor={props.incident.id}
|
||||
detailKind="alert"
|
||||
title="Alert detail"
|
||||
summary={`${severityLabel(props.incident.severity)} · ${formatCode(props.incident.code)}`}
|
||||
sections={buildAlertDetailSections(props.incident)}
|
||||
detailAttributes={{ 'data-truenas-alert-detail-for': props.incident.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,13 +36,13 @@ import {
|
|||
type TrueNASProtectionStatusFilter,
|
||||
} from './truenasPageModel';
|
||||
import {
|
||||
TrueNASInlineDetailTable,
|
||||
compactTrueNASDetailRows,
|
||||
compactTrueNASDetailSections,
|
||||
makeTrueNASDetailRow,
|
||||
type TrueNASDetailSection,
|
||||
type TrueNASDetailTone,
|
||||
} from '@/components/Infrastructure/TrueNASDetailTable';
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
|
||||
const TRUENAS_PROTECTION_STATUS_OPTIONS: PlatformTableFilterOption<TrueNASProtectionStatusFilter>[] =
|
||||
[
|
||||
|
|
@ -176,8 +176,8 @@ const formatPointTime = (point: RecoveryPoint): string => {
|
|||
const sizeLabel = (point: RecoveryPoint): string =>
|
||||
typeof point.sizeBytes === 'number' && point.sizeBytes > 0 ? formatBytes(point.sizeBytes) : '-';
|
||||
|
||||
type ProtectionDetailTone = TrueNASDetailTone;
|
||||
type ProtectionDetailSection = TrueNASDetailSection;
|
||||
type ProtectionDetailTone = DetailValueTone;
|
||||
type ProtectionDetailSection = DetailSection;
|
||||
|
||||
const detailBool = (value?: boolean | null): string | null => {
|
||||
if (value == null) return null;
|
||||
|
|
@ -198,9 +198,7 @@ const detailDateTime = (value?: string | null): string | null => {
|
|||
});
|
||||
};
|
||||
|
||||
const detailRow = makeTrueNASDetailRow;
|
||||
const compactDetailRows = compactTrueNASDetailRows;
|
||||
const compactDetailSections = compactTrueNASDetailSections;
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
const outcomeTone = (point: RecoveryPoint): ProtectionDetailTone => {
|
||||
const outcome = normalizeRecoveryOutcome(point.outcome);
|
||||
|
|
@ -292,15 +290,15 @@ const DatasetCell: Component<{ point: RecoveryPoint; detailToggle?: JSX.Element
|
|||
};
|
||||
|
||||
const ProtectionDetailTable: Component<{ point: RecoveryPoint; onClose: () => void }> = (props) => (
|
||||
<TrueNASInlineDetailTable
|
||||
<InlineDetailPanel
|
||||
testId="truenas-protection-detail"
|
||||
detailFor={props.point.id}
|
||||
detailKind="protection"
|
||||
title="Protection detail"
|
||||
summary={`${kindLabel(mapTrueNASProtectionKind(props.point))} · ${getRecoveryOutcomeLabel(
|
||||
normalizeRecoveryOutcome(props.point.outcome),
|
||||
)}`}
|
||||
sections={buildProtectionDetailSections(props.point)}
|
||||
detailAttributes={{ 'data-truenas-protection-detail-for': props.point.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ import {
|
|||
type TrueNASServiceStatusFilter,
|
||||
} from './truenasPageModel';
|
||||
import {
|
||||
TrueNASInlineDetailTable,
|
||||
compactTrueNASDetailRows,
|
||||
compactTrueNASDetailSections,
|
||||
makeTrueNASDetailRow,
|
||||
type TrueNASDetailSection,
|
||||
type TrueNASDetailTone,
|
||||
} from '@/components/Infrastructure/TrueNASDetailTable';
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
|
||||
const TRUENAS_SERVICE_STATUS_OPTIONS: PlatformTableFilterOption<TrueNASServiceStatusFilter>[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
|
|
@ -81,17 +81,15 @@ const serviceStatusVariant = (
|
|||
return 'muted';
|
||||
};
|
||||
|
||||
type ServiceDetailTone = TrueNASDetailTone;
|
||||
type ServiceDetailSection = TrueNASDetailSection;
|
||||
type ServiceDetailTone = DetailValueTone;
|
||||
type ServiceDetailSection = DetailSection;
|
||||
|
||||
const detailBool = (value?: boolean): string | null => {
|
||||
if (value === undefined) return null;
|
||||
return value ? 'Enabled' : 'Disabled';
|
||||
};
|
||||
|
||||
const detailRow = makeTrueNASDetailRow;
|
||||
const compactDetailRows = compactTrueNASDetailRows;
|
||||
const compactDetailSections = compactTrueNASDetailSections;
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
const serviceTone = (row: TrueNASServiceRow): ServiceDetailTone => {
|
||||
const status = mapTrueNASServiceStatus(row);
|
||||
|
|
@ -145,15 +143,15 @@ const buildServiceDetailSections = (row: TrueNASServiceRow): ServiceDetailSectio
|
|||
};
|
||||
|
||||
const ServiceDetailTable: Component<{ row: TrueNASServiceRow; onClose: () => void }> = (props) => (
|
||||
<TrueNASInlineDetailTable
|
||||
<InlineDetailPanel
|
||||
testId="truenas-service-detail"
|
||||
detailFor={props.row.id}
|
||||
detailKind="service"
|
||||
title="Service detail"
|
||||
summary={`${formatServiceName(props.row.service.service)} · ${titleCase(
|
||||
mapTrueNASServiceStatus(props.row),
|
||||
)}`}
|
||||
sections={buildServiceDetailSections(props.row)}
|
||||
detailAttributes={{ 'data-truenas-service-detail-for': props.row.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
|
|
@ -133,58 +140,71 @@ const detailDateTime = (value?: string): string => {
|
|||
});
|
||||
};
|
||||
|
||||
const DetailField: Component<{ label: string; value: string | undefined }> = (props) => (
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-wide text-muted">{props.label}</dt>
|
||||
<dd class="mt-1 truncate text-xs text-base-content" title={props.value || '-'}>
|
||||
{props.value || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
type ActivityDetailTone = DetailValueTone;
|
||||
type ActivityDetailSection = DetailSection;
|
||||
|
||||
const ActivityDetail: Component<{ activity: VmwareActivityRow; onClose: () => void }> = (props) => {
|
||||
const meta = () => props.activity.resource.vmware;
|
||||
return (
|
||||
<div data-testid="vsphere-activity-detail" class="space-y-3">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-base-content">vSphere activity detail</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted" title={props.activity.title}>
|
||||
{formatActivityKind(props.activity.activityKind)} · {props.activity.title}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailField label="Resource" value={props.activity.resourceName} />
|
||||
<DetailField label="Entity" value={formatEntityType(props.activity.entityType)} />
|
||||
<DetailField label="Managed object" value={props.activity.managedObjectId} />
|
||||
<DetailField label="Kind" value={formatActivityKind(props.activity.activityKind)} />
|
||||
<DetailField label="Activity" value={props.activity.title} />
|
||||
<DetailField label="State" value={formatActivityState(props.activity)} />
|
||||
<DetailField label="Message" value={props.activity.message} />
|
||||
<DetailField label="Description" value={props.activity.description} />
|
||||
<DetailField label="Actor" value={props.activity.actor} />
|
||||
<DetailField label="Native ID" value={props.activity.nativeId} />
|
||||
<DetailField label="vCenter" value={meta()?.connectionName || meta()?.vcenterHost} />
|
||||
<DetailField label="Datacenter" value={meta()?.datacenterName} />
|
||||
<DetailField label="Cluster" value={meta()?.clusterName || meta()?.computeResourceName} />
|
||||
<DetailField label="Occurred" value={detailDateTime(props.activity.occurredAt)} />
|
||||
<DetailField label="Observed" value={detailDateTime(props.activity.observedAt)} />
|
||||
<DetailField label="Source" value={formatIdentifierLabel(props.activity.source)} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
const activityTone = (bucket: VmwareActivityStateBucket): ActivityDetailTone => {
|
||||
if (bucket === 'success') return 'success';
|
||||
if (bucket === 'running') return 'warning';
|
||||
if (bucket === 'failed') return 'danger';
|
||||
return 'muted';
|
||||
};
|
||||
|
||||
const buildActivityDetailSections = (activity: VmwareActivityRow): ActivityDetailSection[] => {
|
||||
const meta = activity.resource.vmware;
|
||||
return compactDetailSections([
|
||||
{
|
||||
label: 'Activity',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Kind', formatActivityKind(activity.activityKind)),
|
||||
detailRow('State', formatActivityState(activity), {
|
||||
tone: activityTone(activity.stateBucket),
|
||||
}),
|
||||
detailRow('Activity', activity.title),
|
||||
detailRow('Message', activity.message),
|
||||
detailRow('Description', activity.description),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Affected resource',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Resource', activity.resourceName),
|
||||
detailRow('Type', formatResourceType(activity.resourceType)),
|
||||
detailRow('Entity', formatEntityType(activity.entityType)),
|
||||
detailRow('Managed object', activity.managedObjectId),
|
||||
detailRow('vCenter', meta?.connectionName || meta?.vcenterHost),
|
||||
detailRow('Datacenter', meta?.datacenterName),
|
||||
detailRow('Cluster', meta?.clusterName || meta?.computeResourceName),
|
||||
detailRow('Resource ID', activity.resourceId),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Source',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Native ID', activity.nativeId),
|
||||
detailRow('Actor', activity.actor),
|
||||
detailRow('Source', formatIdentifierLabel(activity.source)),
|
||||
detailRow('Occurred', detailDateTime(activity.occurredAt)),
|
||||
detailRow('Observed', detailDateTime(activity.observedAt)),
|
||||
]),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const ActivityDetail: Component<{ activity: VmwareActivityRow; onClose: () => void }> = (props) => (
|
||||
<InlineDetailPanel
|
||||
testId="vsphere-activity-detail"
|
||||
detailFor={props.activity.id}
|
||||
title="vSphere activity detail"
|
||||
summary={`${formatActivityKind(props.activity.activityKind)} · ${props.activity.title}`}
|
||||
sections={buildActivityDetailSections(props.activity)}
|
||||
detailAttributes={{ 'data-vsphere-activity-detail-for': props.activity.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VsphereActivityTable: Component<{
|
||||
activity: VmwareActivityRow[];
|
||||
emptyIcon: JSX.Element;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { For, Show, type Component, type JSX } from 'solid-js';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import {
|
||||
InlineDetailPanel,
|
||||
compactDetailRows,
|
||||
compactDetailSections,
|
||||
makeDetailRow,
|
||||
type DetailSection,
|
||||
type DetailValueTone,
|
||||
} from '@/components/shared/DetailSectionTable';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
|
|
@ -135,54 +142,70 @@ const detailDateTime = (value?: string): string => {
|
|||
});
|
||||
};
|
||||
|
||||
const DetailField: Component<{ label: string; value: string | undefined }> = (props) => (
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] font-semibold uppercase tracking-wide text-muted">{props.label}</dt>
|
||||
<dd class="mt-1 truncate text-xs text-base-content" title={props.value || '-'}>
|
||||
{props.value || '-'}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
type AlertDetailTone = DetailValueTone;
|
||||
type AlertDetailSection = DetailSection;
|
||||
|
||||
const AlertDetail: Component<{ incident: VmwareIncidentRow; onClose: () => void }> = (props) => {
|
||||
const meta = () => props.incident.resource.vmware;
|
||||
return (
|
||||
<div data-testid="vsphere-alert-detail" class="space-y-3">
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold text-base-content">vSphere health detail</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted" title={props.incident.summary}>
|
||||
{severityLabel(props.incident.severity)} · {formatCode(props.incident.code)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted hover:bg-surface-hover hover:text-base-content"
|
||||
aria-label="Close"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
const detailRow = makeDetailRow;
|
||||
|
||||
<dl class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailField label="Summary" value={props.incident.summary} />
|
||||
<DetailField label="Signal" value={props.incident.label} />
|
||||
<DetailField label="Severity" value={severityLabel(props.incident.severity)} />
|
||||
<DetailField label="Entity" value={formatEntityType(props.incident.entityType)} />
|
||||
<DetailField label="Managed object" value={props.incident.managedObjectId} />
|
||||
<DetailField label="Resource" value={props.incident.resourceName} />
|
||||
<DetailField label="vCenter" value={meta()?.connectionName || meta()?.vcenterHost} />
|
||||
<DetailField label="Datacenter" value={meta()?.datacenterName} />
|
||||
<DetailField label="Cluster" value={meta()?.clusterName || meta()?.computeResourceName} />
|
||||
<DetailField label="Started" value={detailDateTime(props.incident.startedAt)} />
|
||||
<DetailField label="Source" value={props.incident.source} />
|
||||
<DetailField label="Action" value={props.incident.action} />
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
const alertTone = (severity: VmwareIncidentRow['severityBucket']): AlertDetailTone => {
|
||||
if (severity === 'critical') return 'danger';
|
||||
if (severity === 'warning') return 'warning';
|
||||
return 'muted';
|
||||
};
|
||||
|
||||
const buildAlertDetailSections = (incident: VmwareIncidentRow): AlertDetailSection[] => {
|
||||
const meta = incident.resource.vmware;
|
||||
return compactDetailSections([
|
||||
{
|
||||
label: 'Signal',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Severity', severityLabel(incident.severity), {
|
||||
tone: alertTone(incident.severityBucket),
|
||||
}),
|
||||
detailRow('Summary', incident.summary),
|
||||
detailRow('Signal', incident.label),
|
||||
detailRow('Code', formatCode(incident.code), { title: incident.code }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Affected resource',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Resource', incident.resourceName),
|
||||
detailRow('Type', formatResourceType(incident.resourceType)),
|
||||
detailRow('Entity', formatEntityType(incident.entityType)),
|
||||
detailRow('Managed object', incident.managedObjectId),
|
||||
detailRow('vCenter', meta?.connectionName || meta?.vcenterHost),
|
||||
detailRow('Datacenter', meta?.datacenterName),
|
||||
detailRow('Cluster', meta?.clusterName || meta?.computeResourceName),
|
||||
detailRow('Resource ID', incident.resourceId),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Source',
|
||||
rows: compactDetailRows([
|
||||
detailRow('Started', detailDateTime(incident.startedAt)),
|
||||
detailRow('Provider', incident.source),
|
||||
]),
|
||||
},
|
||||
{
|
||||
label: 'Action',
|
||||
rows: compactDetailRows([detailRow('Recommended', incident.action)]),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const AlertDetail: Component<{ incident: VmwareIncidentRow; onClose: () => void }> = (props) => (
|
||||
<InlineDetailPanel
|
||||
testId="vsphere-alert-detail"
|
||||
detailFor={props.incident.id}
|
||||
title="vSphere health detail"
|
||||
summary={`${severityLabel(props.incident.severity)} · ${formatCode(props.incident.code)}`}
|
||||
sections={buildAlertDetailSections(props.incident)}
|
||||
detailAttributes={{ 'data-vsphere-alert-detail-for': props.incident.id }}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
export const VsphereAlertsTable: Component<{
|
||||
incidents: VmwareIncidentRow[];
|
||||
emptyIcon: JSX.Element;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ describe('VsphereActivityTable', () => {
|
|||
const detail = within(screen.getByTestId('vsphere-activity-detail'));
|
||||
expect(detail.getByText('vSphere activity detail')).toBeInTheDocument();
|
||||
expect(detail.getByText('Managed object')).toBeInTheDocument();
|
||||
expect(detail.getByText('vm-201')).toBeInTheDocument();
|
||||
expect(detail.getAllByText('vm-201').length).toBeGreaterThan(0);
|
||||
expect(detail.getByText('Native ID')).toBeInTheDocument();
|
||||
expect(detail.getByText('task-901')).toBeInTheDocument();
|
||||
expect(detail.getByText('Description')).toBeInTheDocument();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue