Canonicalize inline detail sections

This commit is contained in:
rcourtman 2026-06-12 19:04:12 +01:00
parent e27b981701
commit e6e323c342
19 changed files with 716 additions and 333 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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>
);

View file

@ -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') {

View file

@ -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' ||

View file

@ -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),
);

View file

@ -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>
);

View file

@ -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(');

View file

@ -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);
});
});

View 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),
);

View file

@ -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;

View file

@ -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[];

View file

@ -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}
/>
);

View file

@ -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}
/>
);

View file

@ -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}
/>
);

View file

@ -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;

View file

@ -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;

View file

@ -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();