Canonicalize platform frontend contracts

This commit is contained in:
rcourtman
2026-08-24 18:40:53 +01:00
parent 164e03ad3c
commit a3d7a90b1b
28 changed files with 1097 additions and 498 deletions
@@ -0,0 +1,210 @@
# Frontend platform canonicalization audit — 2026-08-24
## Executive assessment
Pulse already rendered as a substantially coherent product before this slice:
the platform section navigation, framed page tables, dark column-header band,
compact single-line summary rows, semantic status treatment, responsive column
priority, drawer navigation, and History placement were genuinely shared. The
remaining drift risk was concentrated at the seams. Several nested drawer and
inline-detail tables independently rebuilt the same table chrome; four legacy
Proxmox inventory tables retained page-local row-disclosure wiring; and the
shared operator-state editor rebuilt compact native controls instead of using
the form primitives.
No P0 or P1 operator defect was found. The baseline felt like one product, but
the duplicated seams made future density, header, phone-disclosure, and form
changes likely to drift. This slice removes those seams and turns the visual
agreement into shared structure plus static enforcement.
Rendered evidence is stored under:
`/Users/rcourtman/.codex/visualizations/2026/08/24/01a03497-2e33-7a81-95ce-f33967bb968d/platform-audit`
The directory contains a desktop and phone capture for every route in the
coverage matrix, representative open drawers and History tabs, post-change
captures prefixed with `post-`, and independent Computer Use evidence.
## Canonical design inventory
| Pattern | Canonical contract | Shared owner |
| --- | --- | --- |
| Platform section navigation | One scrollable section-tab treatment; active state and narrow overflow behavior are shared while section labels remain platform-specific. | `frontend-modern/src/features/platformPage/sharedPlatformPage.tsx` (`PlatformSectionTabs`) and `frontend-modern/src/components/shared/Subtabs.tsx` |
| Framed page table | One card border, title/header band, dark column-header band, overflow boundary, loading/empty/error presentation, and compact row density. | `sharedPlatformPage.tsx` (`PlatformTableShell`) over `TableCard.tsx`, `TableCardHeader.tsx`, and `Table.tsx` |
| Cardless nested table | Drawers and inline details reuse the page table's table/header/body structure without inventing a second card. Column content remains object-specific. | `sharedPlatformPage.tsx` (`PlatformDetailTable`, `PlatformDetailTableHeader`, `PlatformDetailTableBody`) |
| Summary row interaction | The whole row activates detail by pointer and Enter/Space, exposes focus, `aria-expanded`, and `aria-controls`, and ignores embedded interactive controls. | `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx` (`createPlatformResourceDetailState`, `getPlatformResourceDetailRowInteractionProps`) |
| Disclosure affordance | Desktop may show the shared disclosure button; phone removes that visual chevron because the row is the touch target. The accessible label remains available. | `PlatformResourceDetailTableRow.tsx` (`PlatformResourceDetailToggleButton`) and `SummaryRowActionButton.tsx` |
| Inline detail placement | Expanded content follows its owning row as a full-width table row and preserves table semantics. | `PlatformResourceDetailTableRow.tsx` and `InlineDetailTableRow.tsx` |
| Drawer hierarchy | Attention/problems and actionable context precede inventory detail; Overview does not repeat low-value row data; Manage owns operator overrides; discovery metadata appears only when enabled. | Platform drawers over the shared resource drawer/detail primitives and `ResourceOperatorStateSection.tsx` |
| History | History is a drawer tab, not duplicated on Overview. Range controls live with the chart and share the history component contract. | Shared drawer tabs and `HistoryChart` |
| Compact operator forms | Low-frequency drawer controls use an explicit compact form density while preserving labels, help relationships, errors, and touch behavior. | `frontend-modern/src/components/shared/Form.ts`, `FormSelect.tsx`, and `FormTextarea.tsx` |
| Loading/empty/error | Page tables use the same presentation and copy slots; domain-specific nouns may vary, but the placement, spacing, iconography, and retry boundary do not. | `sharedPlatformPage.tsx` (`PlatformTableLoading`, `PlatformTableEmpty`, `PlatformTableError`) |
| Status and metrics | Semantic meaning selects the status color; bars share the metric primitive. Different metrics are intentional content, not design variants. | Shared status and metric primitives consumed by platform pages |
| Responsive rows | Rows remain compact and single-line, with truncation and column priority. Tables own overflow; the document does not horizontally scroll. | `Table.tsx` and the exported platform table class constants in `sharedPlatformPage.tsx` |
## Rendered route coverage
`D` means 1280×800 inspected and captured. `P` means 390×844 inspected and
captured. All listed routes passed both unless a state note follows.
| Platform | Route / meaningful section | D | P | Additional states exercised |
| --- | --- | :---: | :---: | --- |
| Proxmox | `/proxmox/overview` | ✓ | ✓ | node, guest, and storage drawers; node Overview and History |
| Proxmox | `/proxmox/storage` | ✓ | ✓ | physical disks, grouped/flat behavior, expanded storage rows |
| Proxmox | `/proxmox/replication` | ✓ | ✓ | grouped inventory and row detail |
| Proxmox | `/proxmox/backups/date` | ✓ | ✓ | backup-server row pointer plus Enter/Space expansion |
| Proxmox | `/proxmox/backups/coverage` | ✓ | ✓ | attention state, expanded restore evidence, nested table |
| Proxmox | `/proxmox/ceph` | ✓ | ✓ | expanded cluster detail and cluster drawer |
| Proxmox | `/proxmox/mail` | ✓ | ✓ | expanded gateway detail and gateway drawer |
| Docker | `/docker/overview` | ✓ | ✓ | host and container drawers; host History |
| Docker | `/docker/images` | ✓ | ✓ | image inventory and drawer |
| Docker | `/docker/storage` | ✓ | ✓ | volume/storage inventory and drawer |
| Docker | `/docker/networks` | ✓ | ✓ | network inventory and drawer |
| Docker | `/docker/swarm` | ✓ | ✓ | services, configs, secrets, nested service table |
| Kubernetes | `/kubernetes/overview` | ✓ | ✓ | cluster drawer and alert context |
| Kubernetes | `/kubernetes/nodes` | ✓ | ✓ | node drawer |
| Kubernetes | `/kubernetes/workloads` | ✓ | ✓ | pods, deployments/controllers, drawers |
| Kubernetes | `/kubernetes/services` | ✓ | ✓ | services/networking and drawers |
| Kubernetes | `/kubernetes/storage` | ✓ | ✓ | storage inventory and drawers |
| Kubernetes | `/kubernetes/configuration` | ✓ | ✓ | config, policy, autoscaling, namespaces/deployments detail |
| Kubernetes | `/kubernetes/events` | ✓ | ✓ | events and attention states |
| TrueNAS | `/truenas/overview` | ✓ | ✓ | system drawer and alerts |
| TrueNAS | `/truenas/storage` | ✓ | ✓ | topology, pools/datasets, drawers |
| TrueNAS | `/truenas/services` | ✓ | ✓ | service state and drawer |
| TrueNAS | `/truenas/apps` | ✓ | ✓ | apps and drawer |
| TrueNAS | `/truenas/vms` | ✓ | ✓ | virtual machines and drawer |
| TrueNAS | `/truenas/shares` | ✓ | ✓ | shares and drawer |
| TrueNAS | `/truenas/protection` | ✓ | ✓ | protection and alert/attention state |
| VMware | `/vmware/overview` | ✓ | ✓ | host drawer and History |
| VMware | `/vmware/storage` | ✓ | ✓ | datastore inventory and drawer |
| VMware | `/vmware/networks` | ✓ | ✓ | network inventory and drawer |
| VMware | `/vmware/health` | ✓ | ✓ | alert/attention state |
| VMware | `/vmware/activity` | ✓ | ✓ | activity/history presentation |
| Standalone | `/machines` | ✓ | ✓ | machine drawer, stale/disconnected examples |
| Standalone | `/availability` | ✓ | ✓ | availability-check drawer; intentionally no History |
Loading was independently observed in the native browser while the live local
frontend connected to the backend. Empty and error presentation were traced to
and protected by the shared state components and their focused tests; the mock
estate did not expose every empty/error variant on every route, so those states
were not artificially inferred from healthy pixels.
## Prioritized findings and disposition
### P2 — nested tables only looked shared
- **Rendered evidence:** Docker Swarm, Kubernetes configuration drawers,
Proxmox Ceph, Proxmox Mail Gateway, and Proxmox backup coverage matched the
page-table header and density in desktop and phone captures.
- **Baseline source consumers:** `SwarmServicesDrawer.tsx`,
`K8sDeploymentsDrawer.tsx`, `K8sNamespacesDrawer.tsx`,
`ProxmoxCephClusterDrawer.tsx`, `ProxmoxMailGatewayDrawer.tsx`, and
`ProxmoxCoverageTable.tsx` independently composed raw table/header/body
structure and copied canonical class constants.
- **Operator impact:** operators would notice only after the next shared header,
density, border, or responsive change landed unevenly; the defect was
architectural drift risk rather than current pixel breakage.
- **Disposition:** migrated every consumer to `PlatformDetailTable`,
`PlatformDetailTableHeader`, and `PlatformDetailTableBody`. Registry auditing
now requires those owners and consumers.
### P2 — legacy Proxmox disclosure behavior was page-local
- **Routes/viewports:** `/proxmox/backups/date`,
`/proxmox/backups/coverage`, `/proxmox/ceph`, and `/proxmox/mail` at both
viewports. Post-change phone evidence includes
`post-proxmox-mail-expanded-390x844.png` and
`post-proxmox-backup-coverage-expanded-390x844.png`.
- **Baseline source consumers:** `ProxmoxBackupServersTable.tsx`,
`ProxmoxCoverageTable.tsx`, `ProxmoxCephTable.tsx`, and
`ProxmoxMailGatewayTable.tsx` each carried local click/keyboard/aria state.
- **Operator impact:** a phone row or keyboard path could diverge while newer
platforms changed centrally; visible duplicate chevrons were a recurring
regression risk.
- **Disposition:** all four now consume the shared interaction owner. Browser
verification confirmed pointer, Enter, and Space activation, focusability,
`aria-expanded`, `aria-controls`, and absence of a visible phone disclosure
button.
### P2 — compact operator forms bypassed the form system
- **Rendered surface:** the shared resource drawer Manage tab across platforms.
- **Baseline source owner:** `ResourceOperatorStateSection.tsx` used native
select and textarea shells with page-local classes.
- **Operator impact:** label/help/error behavior and compact density could drift
from other form controls in every resource drawer at once.
- **Disposition:** added documented `compact` density variants to `FormSelect`
and `FormTextarea`, then migrated the shared Manage section. The controls stay
out of Overview because overrides are low-frequency operator actions.
### P3 — drift detection did not encode these contracts
- **Baseline:** matching class strings could pass review without proving shared
ownership, while a few guard patterns produced test-only false positives.
- **Disposition:** the shared-template audit now supports required alternative
ownership patterns, requires the nested-table and row-interaction contracts,
excludes tests from runtime chevron/native-control rules, and retains the
canonical-platform lint gate.
### P3 — unrelated pre-existing shared-template findings
The complete shared-template audit still reports four consumers outside this
platform canonicalization slice: `ApprovalBanner`/`LoadingSpinner`,
`UpdatesSettingsPanel`/`FormSelect`, `RelayPairingSection`/`ExternalTextLink`,
and `PatrolAttentionWorkbench`/`FormSelect`. None is rendered by or newly
introduced in the audited platform inventory/drawer contract. They remain
explicit exceptions rather than being hidden by this slice.
## Decisions for previously ambiguous contracts
1. **Shared structure, object-specific content.** Platform columns, metrics,
health explanations, and technical details remain domain-specific. Table
chrome, density, state presentation, interaction, and responsiveness do not.
2. **Whole row is the phone affordance.** The disclosure button remains in the
accessibility tree and may be visually present on desktop, but phone layouts
do not show a redundant chevron.
3. **History is a drawer destination.** History does not compete with current
operator context on Overview. Availability checks may omit History when the
object has no meaningful historical series.
4. **Operator overrides belong in Manage.** Discovery metadata is conditional;
operator override controls are not promoted into Overview merely to make
them more visible.
5. **Cardless is a supported table variant, not a fork.** Nested tables should
not receive a decorative second card, but they must consume the shared table
anatomy.
6. **State copy may name the object.** Loading/empty/error shells are canonical;
concise domain nouns are supported content rather than inconsistency.
## Remediation sequence applied
1. Established rendered baseline and source ownership across all routes.
2. Added the shared cardless table and whole-row interaction owners.
3. Migrated all known repeated nested-table and legacy Proxmox consumers.
4. Added compact form variants and migrated the cross-platform Manage surface.
5. Added focused interaction/structure/form tests and static registry rules.
6. Updated the frontend-primitives contract.
7. Re-ran representative desktop/phone pixels, expanded rows, nested evidence,
alert/attention states, keyboard activation, and independent Computer Use.
## Patterns already correct and intentionally not churned
- Platform section tabs and narrow horizontal overflow.
- `PlatformTableShell` page-level card/table anatomy.
- Compact single-line summary-row density, truncation, and column priority.
- Shared status colors and metric bars; different provider metrics remain.
- Docker, Kubernetes, TrueNAS, VMware, Machines, and availability whole-row
disclosure consumers already using the shared detail-state contract.
- Drawer tabs, Overview/History placement, range controls, alert-first
hierarchy, and conditional discovery metadata.
- Loading/empty/error component ownership and healthy/stale/disconnected status
semantics.
- Availability check drawers intentionally omitting History when no useful
historical series exists.
## Remaining exceptions
There are no known platform-page exceptions to the canonical table or summary
row interaction contracts after this slice. The four unrelated static-audit
findings listed above remain outside this task. Route-specific cells, metrics,
drawer sections, and technical details are intentional object content inside
the shared contract, not forks.
@@ -107,6 +107,27 @@ on Overview or reintroduce object-local tab or range-selector chrome. The
metrics and chart groups may remain object-specific without changing this
navigation contract.
Platform inventory presentation has one structural owner across provider pages,
drawers, and inline detail rows. `PlatformTableShell` owns framed page tables;
`PlatformDetailTable`, `PlatformDetailTableHeader`, and
`PlatformDetailTableBody` own cardless nested tables while reusing the same
header band, borders, single-line density, responsive table class, and overflow
boundary. Provider-specific columns and cell contents are intentional variants,
but a drawer or expanded row must not rebuild raw `table` / `thead` / `tbody`
chrome or duplicate the shared header class strings.
Expandable platform summary rows use
`getPlatformResourceDetailRowInteractionProps` (or
`createPlatformResourceDetailState`, which owns the same state contract) for
whole-row pointer activation, Enter/Space keyboard activation, focus treatment,
`aria-expanded` / `aria-controls`, and exclusion of embedded links and controls.
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
is visually removed on phone layouts where the complete row is the touch target;
provider tables must not add a second mobile chevron. Operator overrides remain
in the shared `Manage` tab and use the explicit compact density of `FormSelect`
and `FormTextarea`, keeping form labels, help relationships, touch targets, and
control chrome canonical without expanding the low-frequency management surface.
Presentation helpers that mirror a server-side classification must name the
predicate they mirror and expose it as a single exported function rather than
@@ -270,6 +291,10 @@ puts the same machine on two surfaces that do not share an identity.
131c. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx`
131d. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx`
131e. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts`
131f. `frontend-modern/src/components/shared/Form.ts`
131g. `frontend-modern/src/components/shared/FormSelect.tsx`
131h. `frontend-modern/src/components/shared/FormTextarea.tsx`
131i. `frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx`
132. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
133. `frontend-modern/src/utils/platformSupportManifest.ts`
134. `frontend-modern/src/utils/sourcePlatformOptions.ts`
+88 -18
View File
@@ -1,23 +1,81 @@
{
"version": 1,
"base_sha": "baabed91a61acc7b4262c5170e3f5f5a6b457ab1",
"verified_at": "2026-08-24T16:27:42Z",
"base_sha": "164e03ad3c77a50574df782409ee7a417efe8955",
"verified_at": "2026-08-24T17:44:49Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Storage/Storage.tsx",
"frontend-modern/src/components/Storage/StorageContentCard.tsx",
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx"
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx",
"frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx",
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx",
"frontend-modern/src/components/shared/Form.ts",
"frontend-modern/src/components/shared/FormSelect.tsx",
"frontend-modern/src/components/shared/FormTextarea.tsx",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Storage/Storage.tsx": "68ec929f7daf54b809fb9fa508e35ed73613666e242df41cd2c147bdcdb761af",
"frontend-modern/src/components/Storage/StorageContentCard.tsx": "4ec6d2d81673e02e8937695cfe59773f548b5c052554245e44deffc0d44f03af",
"frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "d23551a00a460075996ac78eed48d7cf190dcd40adace7fcfdaf2fa2034b2797"
"frontend-modern/src/components/Docker/SwarmServicesDrawer.tsx": "cfe0382ab9ac0b73ac3d1c0a5c7fdd61e43d1851164cf38071b0d8d99a48fb7e",
"frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "80334e63a0a5560063cf8e3b26e0fd5316989f146f6f014242193c66c51d20a0",
"frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx": "1806e0ca765a2fe73f456760f1e4a6506befcbba9e56bbd5249c7ee685b4b225",
"frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx": "d5d09d77eb54ba91e5e2539c31b5104473ecc73a7e891c50cca4e662975cd294",
"frontend-modern/src/components/shared/Form.ts": "0186bf1fe8d10378d8d603dec8f6b9796302b418e02444a8d18044692815c2b5",
"frontend-modern/src/components/shared/FormSelect.tsx": "38b64e18d93bf7f374e18e29c2f633028c52c4e8b1207a63578f22e0e65c3ec2",
"frontend-modern/src/components/shared/FormTextarea.tsx": "cccbd6e027aabf61eddebab7c9d153737e3b607f64568751cd03c7c138b483f6",
"frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx": "3c07555f296ab58dec752bd2ce8f8ac058257d0f1efd4d3823c1b5220525fd1b",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "97a23102f8aff06e4e271248244d9bc63d9fd61ef8d6afe7705353189a153671",
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b9a2da5b1191687ffcbbb1ebf95a766c303fbe5717dc30a43f80908481d6b02e",
"frontend-modern/src/features/proxmox/ProxmoxCephClusterDrawer.tsx": "aa8df0cef4a602ab81b8d53251d3c1f7b9b4334f18a2caaa0ba71c70f079c73c",
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx": "663ffc8917075275617d1b0ca40e5739429553a855c0e130aa162b2421dca89b",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "276ee693542e06b116cda0b8b6b9771fba1e416bc11f3645a4d265883d6353f2",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "b9119d1611e87d8115c4f8e378b169b0db5750a077fc433f57969877bd6d4b8e",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "f9e3185ca23b59bdfc7f77a2738c2cd9e7d5d768ad10846b5c9b3680750bc17a"
},
"routes": ["/proxmox/storage"],
"routes": [
"/proxmox/overview",
"/proxmox/storage",
"/proxmox/replication",
"/proxmox/backups/date",
"/proxmox/backups/coverage",
"/proxmox/ceph",
"/proxmox/mail",
"/docker/overview",
"/docker/images",
"/docker/storage",
"/docker/networks",
"/docker/swarm",
"/kubernetes/overview",
"/kubernetes/nodes",
"/kubernetes/workloads",
"/kubernetes/services",
"/kubernetes/storage",
"/kubernetes/configuration",
"/kubernetes/events",
"/truenas/overview",
"/truenas/storage",
"/truenas/services",
"/truenas/apps",
"/truenas/vms",
"/truenas/shares",
"/truenas/protection",
"/vmware/overview",
"/vmware/storage",
"/vmware/networks",
"/vmware/health",
"/vmware/activity",
"/machines",
"/availability"
],
"viewports": [
{
"width": 1280,
"height": 720
"height": 800
},
{
"width": 390,
@@ -25,15 +83,27 @@
}
],
"states": [
"Proxmox Storage with plain desktop table headings",
"Proxmox Storage after sorting by usage and changing direction through View preferences",
"Proxmox Storage compact phone table with Filters and View preferences open"
"default populated inventories for every Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines, and availability route",
"live loading state while platform resources connected",
"healthy, attention, alert, stale, and disconnected resource presentations",
"grouped and flat inventory views where available",
"expanded Proxmox backup server, backup coverage evidence, Ceph cluster, and Mail Gateway rows",
"node, guest, storage, host, cluster, system, machine, and availability drawers",
"drawer Overview, Manage, Discovery when enabled, and History with range controls",
"cardless Docker Swarm, Kubernetes namespace and deployment, Ceph, Mail Gateway, and restore-evidence tables",
"rebased Proxmox Storage table with the newer mainline plain-header contract"
],
"interactions": [
"confirmed every desktop Storage table heading contains no button, aria-sort state, or visible direction marker",
"selected Usage in View -> Order and changed the sort direction, confirming the rendered row order changed while headings remained plain",
"confirmed compact phone headings render Storage, State, Type, Host, Prot, and Used without direction markers",
"opened phone Filters -> View and confirmed Sort by and Sort direction remain available",
"confirmed the page has no horizontal overflow at 390 pixels"
"activated legacy Proxmox rows by whole-row pointer click and confirmed aria-expanded and aria-controls",
"activated Mail Gateway detail with Enter and Space and confirmed both keyboard paths toggle the same row",
"confirmed embedded controls do not trigger the owning summary row",
"confirmed desktop disclosure controls remain available and phone disclosure buttons have no visible footprint",
"opened backup coverage restore evidence and confirmed the nested canonical table header and body",
"opened representative platform drawers, switched to History, and inspected chart range placement",
"inspected Manage controls using the compact shared select and textarea presentation",
"confirmed no document-level horizontal overflow at 1280 by 800 or 390 by 844",
"independently inspected the fully rendered post-change backup coverage route with Computer Use in Safari",
"rechecked the rebased Storage route at exactly 1280 by 800 and backup coverage at exactly 390 by 844",
"confirmed phone disclosure controls remain accessibility-exposed while their clipped visual footprint leaves the whole row as the activation affordance"
]
}
@@ -197,8 +197,11 @@ for (const guard of requiredPatternGuards) {
}
const requiredPatterns = Array.isArray(guard.requiredPatterns) ? guard.requiredPatterns : [];
if (requiredPatterns.length === 0) {
failures.push(`${guard.id}: missing requiredPatterns`);
const requiredAnyPatterns = Array.isArray(guard.requiredAnyPatterns)
? guard.requiredAnyPatterns
: [];
if (requiredPatterns.length === 0 && requiredAnyPatterns.length === 0) {
failures.push(`${guard.id}: missing requiredPatterns or requiredAnyPatterns`);
continue;
}
@@ -238,6 +241,17 @@ for (const guard of requiredPatternGuards) {
break;
}
}
if (
requiredAnyPatterns.length > 0 &&
!requiredAnyPatterns.some((pattern) => source.includes(pattern))
) {
failures.push(
`${guard.id}: ${file} matches ${JSON.stringify(
triggerPatterns,
)} but does not compose one of ${JSON.stringify(requiredAnyPatterns)}`,
);
}
}
for (const allowedPath of allowedPaths) {
@@ -2166,6 +2166,28 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-detail-table-shell",
"category": "platform-table-shell",
"summary": "Cardless tables inside platform drawers and inline detail rows compose the canonical platform table structure instead of duplicating header, body, density, and overflow chrome.",
"canonical": {
"path": "src/features/platformPage/sharedPlatformPage.tsx",
"export": "PlatformDetailTable"
},
"requiredConsumers": [
{ "path": "src/components/Docker/SwarmServicesDrawer.tsx" },
{ "path": "src/components/Kubernetes/K8sDeploymentsDrawer.tsx" },
{ "path": "src/components/Kubernetes/K8sNamespacesDrawer.tsx" },
{ "path": "src/features/proxmox/ProxmoxCephClusterDrawer.tsx" },
{ "path": "src/features/proxmox/ProxmoxCoverageTable.tsx" },
{ "path": "src/features/proxmox/ProxmoxMailGatewayDrawer.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/features/platformPage/__tests__/sharedPlatformPage.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-table-loading-state",
"category": "platform-table-loading-state",
@@ -4728,7 +4750,10 @@
"src/features/vmware"
],
"extensions": [".tsx"],
"allPatterns": ["flex justify-center", "text-xs text-muted", "aria-hidden=\"true\""],
"allPatterns": [
"class=\"flex justify-center\"",
"class=\"text-xs text-muted\" aria-hidden=\"true\""
],
"legacyReason": "Retired migration debt. Platform metric cells must compose PlatformTableMetricFallback instead of carrying inline centered dash fallback markup.",
"allowedPaths": [],
"ignoredPaths": [],
@@ -5256,6 +5281,7 @@
},
"scopes": ["src/components", "src/features"],
"extensions": [".tsx"],
"pathExcludes": [".test.", "__tests__"],
"allPatterns": ["<select"],
"legacyReason": "Retired migration debt. Native select controls must use FormSelect for label/id/select chrome wiring and dynamic option value synchronization.",
"allowedPaths": [],
@@ -6566,12 +6592,43 @@
"extensions": [".tsx"],
"triggerPatterns": ["data-inline", "aria-expanded"],
"requiredPatterns": ["PlatformResourceDetailToggleButton"],
"pathExcludes": ["__tests__"],
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-row-detail-whole-row-activation",
"category": "row-disclosure",
"summary": "Platform inline-detail tables compose the canonical whole-row pointer, keyboard, focus, and aria interaction contract.",
"canonical": {
"path": "src/features/platformPage/PlatformResourceDetailTableRow.tsx",
"export": "getPlatformResourceDetailRowInteractionProps"
},
"scopes": [
"src/features/docker",
"src/features/kubernetes",
"src/features/proxmox",
"src/features/standalone",
"src/features/truenas",
"src/features/vmware"
],
"extensions": [".tsx"],
"triggerPatterns": ["PlatformResourceDetailToggleButton", "data-inline-detail-for"],
"requiredAnyPatterns": [
"createPlatformResourceDetailState",
"getPlatformResourceDetailRowInteractionProps"
],
"pathExcludes": ["__tests__"],
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/features/platformPage/__tests__/PlatformResourceDetailTableRow.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "inline-detail-table-row-shared-shell",
"category": "row-detail",
@@ -6648,6 +6705,7 @@
"extensions": [".tsx"],
"triggerPatterns": ["detail-row", "TableRow"],
"requiredPatterns": ["InlineDetailTableRow"],
"pathExcludes": ["__tests__"],
"allowedPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
@@ -4,20 +4,14 @@ import { apiFetchJSON } from '@/utils/apiClient';
import { Card } from '@/components/shared/Card';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/shared/Table';
import { TableRow, TableHead, TableCell } from '@/components/shared/Table';
import { EmptyState } from '@/components/shared/EmptyState';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
PlatformTableNumberValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
@@ -269,50 +263,48 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
{drawerPresentation.serviceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[13%] sm:table-cell`}
>
{drawerPresentation.stackColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[22%]`}
>
{drawerPresentation.imageColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[10%]`}
>
{drawerPresentation.modeColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}
>
{drawerPresentation.desiredColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}
>
{drawerPresentation.runningColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[12%] sm:table-cell`}
>
{drawerPresentation.updateColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[9%]`}
>
{drawerPresentation.portsColumnLabel}
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
{drawerPresentation.serviceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[13%] sm:table-cell`}
>
{drawerPresentation.stackColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[22%]`}
>
{drawerPresentation.imageColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[10%]`}
>
{drawerPresentation.modeColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}
>
{drawerPresentation.desiredColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}
>
{drawerPresentation.runningColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[12%] sm:table-cell`}
>
{drawerPresentation.updateColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} swarm-service-detail-column md:w-[9%]`}
>
{drawerPresentation.portsColumnLabel}
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={filteredServices()}>
{(svc) => {
const name = () => asTrimmedString(svc.name) || svc.id;
@@ -387,8 +379,8 @@ export const SwarmServicesDrawer: Component<{ cluster: string; swarm?: SwarmInfo
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Card>
</Show>
}
@@ -1,5 +1,7 @@
import { Component, For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { Toggle } from '@/components/shared/Toggle';
import { FormSelect } from '@/components/shared/FormSelect';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { notificationStore } from '@/stores/notifications';
import {
type ResourceCriticality,
@@ -527,35 +529,30 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</Show>
<div class="grid grid-cols-1 gap-3 pt-2 border-t border-border-subtle sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
<label class="block">
<span class="block text-sm font-medium text-base-content">Patrol priority</span>
<select
value={criticality()}
onChange={(e) => setCriticality(e.currentTarget.value as ResourceCriticality)}
disabled={saving()}
class="mt-1 block min-h-11 w-full text-xs rounded border border-border bg-surface px-2 py-1.5 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:opacity-50 sm:min-h-0"
>
<option value="">Default</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
<span class="mt-1 block text-[11px] text-muted leading-tight">
Orders this resource among same-severity Patrol findings.
</span>
</label>
<FormSelect
label="Patrol priority"
density="compact"
value={criticality()}
onChange={(e) => setCriticality(e.currentTarget.value as ResourceCriticality)}
disabled={saving()}
help="Orders this resource among same-severity Patrol findings."
helpClass="text-[11px] leading-tight"
>
<option value="">Default</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</FormSelect>
<label class="block">
<span class="block text-sm font-medium text-base-content">Operator note</span>
<textarea
value={note()}
onInput={(e) => setNote(e.currentTarget.value)}
placeholder="e.g. Production database. Page before rebooting"
class="mt-1 block min-h-16 w-full resize-y text-xs rounded border border-border bg-surface px-2 py-1.5 text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:opacity-50"
disabled={saving()}
maxLength={500}
/>
</label>
<FormTextarea
label="Operator note"
density="compact"
value={note()}
onInput={(e) => setNote(e.currentTarget.value)}
placeholder="e.g. Production database. Page before rebooting"
disabled={saving()}
maxLength={500}
/>
</div>
{/* Maintenance window scheduler. The form is closed by default;
@@ -810,43 +807,36 @@ export const ResourceOperatorStateSection: Component<ResourceOperatorStateSectio
</Show>
<div class="grid grid-cols-1 gap-3 border-t border-border-subtle pt-3 sm:grid-cols-2">
<label class="block">
<span class="block text-sm font-medium text-base-content">Monitoring</span>
<select
value={monitoringMode()}
onChange={(event) =>
setMonitoringMode(event.currentTarget.value as ResourceMonitoringMode)
}
disabled={saving() || lifecycleState() === 'retired'}
class="mt-1 block min-h-11 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:opacity-50 sm:min-h-0"
>
<option value="normal">Normal monitoring</option>
<option value="expected_offline">Expected offline</option>
<option value="muted">Mute all attention</option>
</select>
<span class="mt-1 block text-[11px] leading-tight text-muted">
Expected offline hides availability noise only. Mute all stops Alerts and Patrol while
keeping this resource visible.
</span>
</label>
<FormSelect
label="Monitoring"
density="compact"
value={monitoringMode()}
onChange={(event) =>
setMonitoringMode(event.currentTarget.value as ResourceMonitoringMode)
}
disabled={saving() || lifecycleState() === 'retired'}
help="Expected offline hides availability noise only. Mute all stops Alerts and Patrol while keeping this resource visible."
helpClass="text-[11px] leading-tight"
>
<option value="normal">Normal monitoring</option>
<option value="expected_offline">Expected offline</option>
<option value="muted">Mute all attention</option>
</FormSelect>
<label class="block">
<span class="block text-sm font-medium text-base-content">Lifecycle</span>
<select
value={lifecycleState()}
onChange={(event) =>
setLifecycleState(event.currentTarget.value as ResourceLifecycleState)
}
disabled={saving()}
class="mt-1 block min-h-11 w-full rounded border border-border bg-surface px-2 py-1.5 text-xs text-base-content focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:opacity-50 sm:min-h-0"
>
<option value="active">Active</option>
<option value="retired">Retired from monitoring</option>
</select>
<span class="mt-1 block text-[11px] leading-tight text-muted">
{inventoryOwnership().retirementDescription}
</span>
</label>
<FormSelect
label="Lifecycle"
density="compact"
value={lifecycleState()}
onChange={(event) =>
setLifecycleState(event.currentTarget.value as ResourceLifecycleState)
}
disabled={saving()}
help={inventoryOwnership().retirementDescription}
helpClass="text-[11px] leading-tight"
>
<option value="active">Active</option>
<option value="retired">Retired from monitoring</option>
</FormSelect>
</div>
<Show when={lifecycleState() === 'retired'}>
@@ -7,8 +7,8 @@ const sectionSource = readFileSync(
'utf-8',
);
const overviewTabSource = readFileSync(
resolve(__dirname, '..', 'ResourceDetailDrawerOverviewTab.tsx'),
const resourceDetailDrawerSource = readFileSync(
resolve(__dirname, '..', 'ResourceDetailDrawer.tsx'),
'utf-8',
);
@@ -17,8 +17,8 @@ const supportDisclosureSource = readFileSync(
'utf-8',
);
const guestOverviewSource = readFileSync(
resolve(__dirname, '..', '..', 'Workloads', 'GuestDrawerOverview.tsx'),
const guestManageSource = readFileSync(
resolve(__dirname, '..', '..', 'Workloads', 'GuestDrawerManage.tsx'),
'utf-8',
);
@@ -180,7 +180,9 @@ describe('ResourceOperatorStateSection', () => {
it('keeps drawer disclosure and operator controls touch-safe on phones', () => {
expect(supportDisclosureSource).toContain('inline-flex min-h-11 shrink-0 items-center');
expect(sectionSource).toContain('min-h-11 w-full text-xs rounded');
expect(sectionSource).toContain('density="compact"');
expect(sectionSource).toContain('<FormSelect');
expect(sectionSource).toContain('<FormTextarea');
expect(sectionSource).toContain('min-h-11 min-w-11 px-1.5 py-0.5');
expect(sectionSource).toContain('min-h-11 px-2.5 py-1 text-xs font-medium text-base-content');
expect(sectionSource).toContain(
@@ -208,18 +210,18 @@ describe('ResourceOperatorStateSection', () => {
});
});
describe('ResourceDetailDrawerOverviewTab integration', () => {
describe('ResourceDetailDrawer manage-tab integration', () => {
it('renders ResourceOperatorStateSection alongside ResourceActionHistory', () => {
// The operator-set state and the action audit history are
// conceptually paired — what the operator decided to suppress, and
// what Pulse actually did. They belong on the same drawer surface
// so the operator can read both stories together.
expect(overviewTabSource).toContain("from './ResourceOperatorStateSection'");
expect(overviewTabSource).toContain('capabilities={resource.capabilities}');
expect(resourceDetailDrawerSource).toContain("from './ResourceOperatorStateSection'");
expect(resourceDetailDrawerSource).toContain('capabilities={props.resource.capabilities}');
// Section must precede the action-history block so the override
// explains the actions that follow, not vice versa.
const operatorIndex = overviewTabSource.indexOf('<ResourceOperatorStateSection');
const historyIndex = overviewTabSource.indexOf('<ResourceActionHistory');
const operatorIndex = resourceDetailDrawerSource.indexOf('<ResourceOperatorStateSection');
const historyIndex = resourceDetailDrawerSource.indexOf('<ResourceActionHistory');
expect(operatorIndex).toBeGreaterThan(0);
expect(historyIndex).toBeGreaterThan(0);
expect(operatorIndex).toBeLessThan(historyIndex);
@@ -233,19 +235,19 @@ describe('ResourceDetailDrawerOverviewTab integration', () => {
// The overrides don't depend on capabilities — only the
// automatic-actions block does, and it self-gates inside the section
// via eligibleAutoCapabilities.
expect(overviewTabSource).toContain('<Show when={resource.id}>');
expect(overviewTabSource).not.toContain('shouldRenderOperatorStateSection');
expect(resourceDetailDrawerSource).toContain('resourceId={props.resource.id}');
expect(resourceDetailDrawerSource).not.toContain('shouldRenderOperatorStateSection');
expect(sectionSource).toContain('<Show when={eligibleAutoCapabilities().length > 0}>');
});
});
describe('Proxmox guest drawer integration', () => {
it('exposes the same canonical policy with provider ownership context', () => {
expect(guestOverviewSource).toContain(
expect(guestManageSource).toContain(
"from '@/components/Infrastructure/ResourceOperatorStateSection'",
);
expect(guestOverviewSource).toContain('<ResourceOperatorStateSection');
expect(guestOverviewSource).toContain('resourceId={props.guestId}');
expect(guestOverviewSource).toContain('platformType="proxmox"');
expect(guestManageSource).toContain('<ResourceOperatorStateSection');
expect(guestManageSource).toContain('resourceId={props.resourceId}');
expect(guestManageSource).toContain("platformType={props.guest.platformType || 'proxmox'}");
});
});
@@ -11,19 +11,13 @@ import {
} from '@/components/shared/FilterToolbar';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/shared/Table';
import { TableRow, TableHead, TableCell } from '@/components/shared/Table';
import { EmptyState } from '@/components/shared/EmptyState';
import { buildKubernetesPath } from '@/routing/resourceLinks';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
PlatformResponsiveTableLabel,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
@@ -243,43 +237,41 @@ export const K8sDeploymentsDrawer: Component<{
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[26%]`}>
{drawerPresentation.deploymentColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[16%] sm:table-cell`}
>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.desiredColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.updatedColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
>
{drawerPresentation.readyColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
>
{drawerPresentation.availableColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
{drawerPresentation.actionsColumnLabel}
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[26%]`}>
{drawerPresentation.deploymentColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:w-[16%] sm:table-cell`}
>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.desiredColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} k8s-deployment-detail-column md:w-[10%]`}
>
{drawerPresentation.updatedColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
>
{drawerPresentation.readyColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
>
{drawerPresentation.availableColumnLabel}
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
{drawerPresentation.actionsColumnLabel}
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={filteredDeployments()}>
{(dep) => {
const name = () => asTrimmedString(dep.name) || dep.id;
@@ -350,8 +342,8 @@ export const K8sDeploymentsDrawer: Component<{
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Card>
</Show>
}
@@ -4,20 +4,14 @@ import { useNavigate } from '@solidjs/router';
import { apiFetchJSON } from '@/utils/apiClient';
import { Card } from '@/components/shared/Card';
import { SearchInput } from '@/components/shared/SearchInput';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/shared/Table';
import { TableRow, TableHead, TableCell } from '@/components/shared/Table';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { buildKubernetesPath } from '@/routing/resourceLinks';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
PlatformResponsiveTableLabel,
formatPlatformTableIntegerValue,
getPlatformTableCellClassForKind,
@@ -149,30 +143,28 @@ export const K8sNamespacesDrawer: Component<{
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[18%]`}
>
{drawerPresentation.podsColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[22%]`}
>
{drawerPresentation.deploymentsColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-30 md:w-[38%]`}
>
{drawerPresentation.actionsColumnLabel}
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[22%]`}>
{drawerPresentation.namespaceColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[18%]`}
>
{drawerPresentation.podsColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[22%]`}
>
{drawerPresentation.deploymentsColumnLabel}
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-30 md:w-[38%]`}
>
{drawerPresentation.actionsColumnLabel}
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={filteredRows()}>
{(row) => {
const podIndicator = () => getNamespaceCountsIndicator(row.pods);
@@ -256,8 +248,8 @@ export const K8sNamespacesDrawer: Component<{
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Card>
</Show>
</Show>
@@ -5,6 +5,10 @@ const baseControl = [
'w-full min-h-11 sm:min-h-9 rounded-md border border-border bg-surface px-3 py-2.5 text-sm text-base-content',
'focus:outline-none focus:ring-0 focus:border-blue-500 transition-colors',
].join(' ');
const compactControl = [
'w-full min-h-11 sm:min-h-8 rounded border border-border bg-surface px-2 py-1.5 text-xs text-base-content',
'focus:outline-none focus:ring-0 focus:border-blue-500 transition-colors',
].join(' ');
const baseCheckbox =
'h-4 w-4 rounded border-border text-blue-600 focus:ring-0 focus:ring-offset-0 transition-colors';
@@ -23,6 +27,8 @@ export const formControlMono = join(baseControl, 'font-mono');
export const formSelect = join(baseControl, 'pr-8 appearance-none');
export const formTextarea = join(baseControl, 'min-h-[120px] resize-vertical');
export const formSelectCompact = join(compactControl, 'pr-8 appearance-none');
export const formTextareaCompact = join(compactControl, 'min-h-16 resize-y');
export const formLabelMuted = join(baseLabel, 'text-muted font-normal');
@@ -46,6 +52,8 @@ export default {
formControlMono,
formSelect,
formTextarea,
formSelectCompact,
formTextareaCompact,
formCheckbox,
labelClass,
controlClass,
@@ -42,6 +42,22 @@ describe('FormSelect', () => {
expect(select).toHaveClass('w-auto');
});
it('provides a canonical compact drawer density', () => {
render(() => (
<FormSelect label="Monitoring" value="normal" density="compact">
<option value="normal">Normal monitoring</option>
</FormSelect>
));
expect(screen.getByLabelText('Monitoring')).toHaveClass(
'min-h-11',
'sm:min-h-8',
'px-2',
'py-1.5',
'text-xs',
);
});
it('connects helper text without dropping an existing description', () => {
render(() => (
<>
@@ -1,6 +1,12 @@
import { Show, createEffect, createUniqueId, onCleanup, splitProps } from 'solid-js';
import type { Component, JSX } from 'solid-js';
import { formField, formHelpText, formLabel, formSelect } from '@/components/shared/Form';
import {
formField,
formHelpText,
formLabel,
formSelect,
formSelectCompact,
} from '@/components/shared/Form';
interface FormSelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> {
label: JSX.Element;
@@ -10,6 +16,7 @@ interface FormSelectProps extends JSX.SelectHTMLAttributes<HTMLSelectElement> {
labelClass?: string;
selectBaseClass?: string;
selectClass?: string;
density?: 'default' | 'compact';
help?: JSX.Element;
helpClass?: string;
}
@@ -31,6 +38,7 @@ export const FormSelect: Component<FormSelectProps> = (props) => {
'class',
'selectBaseClass',
'selectClass',
'density',
'value',
]);
let selectElement: HTMLSelectElement | undefined;
@@ -96,7 +104,11 @@ export const FormSelect: Component<FormSelectProps> = (props) => {
{...selectProps}
id={selectId()}
aria-describedby={describedBy()}
class={joinClass(local.selectBaseClass ?? formSelect, local.class, local.selectClass)}
class={joinClass(
local.selectBaseClass ?? (local.density === 'compact' ? formSelectCompact : formSelect),
local.class,
local.selectClass,
)}
>
{local.children}
</select>
@@ -34,6 +34,18 @@ describe('FormTextarea', () => {
expect(textarea).toHaveClass('min-h-16');
});
it('provides a canonical compact drawer density', () => {
render(() => <FormTextarea label="Operator note" value="" density="compact" />);
expect(screen.getByLabelText('Operator note')).toHaveClass(
'min-h-16',
'px-2',
'py-1.5',
'text-xs',
'resize-y',
);
});
it('connects helper text without dropping an existing description', () => {
render(() => (
<>
@@ -1,6 +1,12 @@
import { Show, createEffect, createUniqueId, splitProps } from 'solid-js';
import type { Component, JSX } from 'solid-js';
import { formField, formHelpText, formLabel, formTextarea } from '@/components/shared/Form';
import {
formField,
formHelpText,
formLabel,
formTextarea,
formTextareaCompact,
} from '@/components/shared/Form';
interface FormTextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
label: JSX.Element;
@@ -9,6 +15,7 @@ interface FormTextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaEleme
labelClass?: string;
textareaBaseClass?: string;
textareaClass?: string;
density?: 'default' | 'compact';
help?: JSX.Element;
helpClass?: string;
}
@@ -28,6 +35,7 @@ export const FormTextarea: Component<FormTextareaProps> = (props) => {
'class',
'textareaBaseClass',
'textareaClass',
'density',
'value',
]);
let textareaElement: HTMLTextAreaElement | undefined;
@@ -68,7 +76,12 @@ export const FormTextarea: Component<FormTextareaProps> = (props) => {
{...textareaProps}
id={textareaId()}
aria-describedby={describedBy()}
class={joinClass(local.textareaBaseClass ?? formTextarea, local.class, local.textareaClass)}
class={joinClass(
local.textareaBaseClass ??
(local.density === 'compact' ? formTextareaCompact : formTextarea),
local.class,
local.textareaClass,
)}
/>
<Show when={local.help}>
{(help) => (
@@ -723,7 +723,9 @@ describe('shared primitive guardrails', () => {
expect(formSelectSource).toContain('MutationObserver');
expect(formSelectSource).toContain("'value'");
expect(formSelectSource).toContain('selectElement.value = resolvedValue');
expect(formSelectSource).toContain('local.selectBaseClass ?? formSelect');
expect(formSelectSource).toContain(
"local.density === 'compact' ? formSelectCompact : formSelect",
);
expect(formSelectSource).toContain('local.fieldBaseClass ?? formField');
for (const source of [
@@ -814,7 +816,9 @@ describe('shared primitive guardrails', () => {
expect(formTextareaSource).toContain('createEffect');
expect(formTextareaSource).toContain("'value'");
expect(formTextareaSource).toContain('textareaElement.value = nextValue');
expect(formTextareaSource).toContain('local.textareaBaseClass ?? formTextarea');
expect(formTextareaSource).toContain(
"local.density === 'compact' ? formTextareaCompact : formTextarea",
);
expect(formTextareaSource).toContain('local.fieldBaseClass ?? formField');
const migratedConsumers = [
@@ -5332,14 +5336,18 @@ describe('shared primitive guardrails', () => {
'getPlatformTableRowClass = (): string => PLATFORM_TABLE_SUMMARY_ROW_CLASS',
);
expect(sharedPlatformPageSource).toContain('TableCard class={props.cardClass');
expect(sharedPlatformPageSource).toContain('TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}');
expect(sharedPlatformPageSource).toContain('TableBody class={PLATFORM_TABLE_BODY_CLASS}');
expect(sharedPlatformPageSource).toContain('export function PlatformDetailTable');
expect(sharedPlatformPageSource).toContain('export function PlatformDetailTableHeader');
expect(sharedPlatformPageSource).toContain('export function PlatformDetailTableBody');
expect(sharedPlatformPageSource).toContain('class={`${PLATFORM_TABLE_HEADER_ROW_CLASS}');
expect(sharedPlatformPageSource).toContain('class={`${PLATFORM_TABLE_BODY_CLASS}');
expect(sharedPlatformPageSource).toContain(
"export const PLATFORM_TABLE_DEFAULT_RESPONSIVE_MIN_WIDTH_CLASS = 'min-w-[0px]'",
);
expect(sharedPlatformPageSource).toContain('export function getPlatformTableClass');
expect(sharedPlatformPageSource).toContain("className !== 'min-w-full'");
expect(sharedPlatformPageSource).toContain('class={getPlatformTableClass(props.tableClass)}');
expect(sharedPlatformPageSource).toContain('<PlatformDetailTable');
expect(sharedPlatformPageSource).toContain('class={props.tableClass}');
expect(sharedPlatformPageSource).toContain('export function createPlatformTablePreview');
expect(sharedPlatformPageSource).toContain('export function PlatformTablePreviewFooter');
expect(sharedPlatformPageSource).toContain('data-platform-table-preview-footer');
@@ -7218,9 +7226,8 @@ describe('shared primitive guardrails', () => {
];
const platformMetricConsumerPaths = platformMetricConsumers.map(([path]) => path);
const inlineMetricMarkerPatterns = [
'flex justify-center',
'text-xs text-muted',
'aria-hidden="true"',
'class="flex justify-center"',
'class="text-xs text-muted" aria-hidden="true"',
];
expect(registeredRule?.canonical?.path).toBe(
@@ -7,6 +7,13 @@ import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'
type ResourceLike = Pick<Resource, 'id'>;
export type PlatformResourceDetailRowInteractionOptions = {
expanded: boolean;
detailRowId: string;
onToggle: () => void;
class?: string;
};
export type PlatformResourceDetailState = {
expandedResourceId: () => string | null;
isExpanded: (resource: ResourceLike) => boolean;
@@ -25,6 +32,44 @@ export const PLATFORM_RESOURCE_DETAIL_ROW_CLASS =
export const getPlatformResourceDetailRowClass = (expanded: boolean): string =>
`${PLATFORM_RESOURCE_DETAIL_ROW_CLASS}${expanded ? ' bg-surface-hover' : ''}`;
const isInteractiveDetailRowDescendant = (event: MouseEvent | KeyboardEvent): boolean => {
const target = event.target;
const currentTarget = event.currentTarget;
if (
!(target instanceof Element) ||
!(currentTarget instanceof Element) ||
target === currentTarget
) {
return false;
}
return Boolean(
target.closest('a, button, input, select, textarea, [role="button"], [role="link"]'),
);
};
// Canonical whole-row disclosure contract. Keeping pointer, keyboard, focus,
// aria linkage, and interactive-child exclusion together prevents a table
// from becoming mouse-only (or from opening when an embedded link is used).
export function getPlatformResourceDetailRowInteractionProps(
options: PlatformResourceDetailRowInteractionOptions,
): JSX.HTMLAttributes<HTMLTableRowElement> {
return {
class: `${getPlatformResourceDetailRowClass(options.expanded)} ${options.class ?? ''}`.trim(),
onClick: (event) => {
if (!isInteractiveDetailRowDescendant(event)) options.onToggle();
},
onKeyDown: (event) => {
if (isInteractiveDetailRowDescendant(event)) return;
if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Space') return;
event.preventDefault();
options.onToggle();
},
tabIndex: 0,
'aria-expanded': options.expanded,
'aria-controls': options.detailRowId,
};
}
export const PlatformResourceDetailToggleButton: Component<{
expanded: boolean;
resourceLabel: string;
@@ -0,0 +1,32 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import {
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
} from '../sharedPlatformPage';
afterEach(cleanup);
describe('PlatformDetailTable', () => {
it('owns the same canonical structure for drawer and inline-detail tables', () => {
render(() => (
<PlatformDetailTable class="table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead>Resource</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<TableRow>
<TableCell>node-1</TableCell>
</TableRow>
</PlatformDetailTableBody>
</PlatformDetailTable>
));
const table = screen.getByRole('table');
expect(table).toHaveClass('platform-table', 'min-w-[0px]', 'table-fixed', 'text-xs');
expect(table.querySelector('thead tr')).toHaveClass('bg-surface-alt', 'border-border');
expect(table.querySelector('tbody')).toHaveClass('divide-y', 'divide-border');
});
});
@@ -0,0 +1,51 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getPlatformResourceDetailRowInteractionProps } from '../PlatformResourceDetailTableRow';
afterEach(cleanup);
describe('getPlatformResourceDetailRowInteractionProps', () => {
const renderRow = (onToggle = vi.fn()) => {
render(() => (
<table>
<tbody>
<TableRow
{...getPlatformResourceDetailRowInteractionProps({
expanded: false,
detailRowId: 'detail-row',
onToggle,
})}
>
<TableCell>
<span>Resource</span>
<button type="button">Open link action</button>
</TableCell>
</TableRow>
</tbody>
</table>
));
return { onToggle, row: screen.getByRole('row'), childAction: screen.getByRole('button') };
};
it('owns pointer, focus, keyboard, and aria disclosure semantics', () => {
const { onToggle, row } = renderRow();
expect(row).toHaveAttribute('tabindex', '0');
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(row).toHaveAttribute('aria-controls', 'detail-row');
fireEvent.click(row);
fireEvent.keyDown(row, { key: 'Enter' });
fireEvent.keyDown(row, { key: ' ' });
expect(onToggle).toHaveBeenCalledTimes(3);
});
it('does not hijack embedded interactive controls', () => {
const { onToggle, childAction } = renderRow();
fireEvent.click(childAction);
fireEvent.keyDown(childAction, { key: 'Enter' });
expect(onToggle).not.toHaveBeenCalled();
});
});
@@ -257,11 +257,16 @@ describe('platform overview layout guardrails', () => {
it('keeps Proxmox inline detail tables on shared platform table primitives', () => {
for (const source of proxmoxInlineDetailTableSources) {
expect(source).toContain("from '@/components/shared/Table'");
expect(source).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(source).toContain('PLATFORM_TABLE_BODY_CLASS');
expect(source).toContain('PlatformDetailTable');
expect(source).toContain('PlatformDetailTableHeader');
expect(source).toContain('PlatformDetailTableBody');
expect(source).toContain('getPlatformTableHeadClassForKind');
expect(source).toContain('getPlatformTableCellClassForKind');
expect(source).not.toMatch(/import \{[^}]*\bTable\b[^}]*\} from '@\/components\/shared\/Table'/);
expect(source).not.toMatch(/import \{[^}]*\bTableHeader\b[^}]*\} from '@\/components\/shared\/Table'/);
expect(source).not.toMatch(/import \{[^}]*\bTableBody\b[^}]*\} from '@\/components\/shared\/Table'/);
expect(source).not.toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(source).not.toContain('PLATFORM_TABLE_BODY_CLASS');
expect(source).not.toContain('<table');
expect(source).not.toContain('<thead');
expect(source).not.toContain('<tbody');
@@ -270,7 +275,12 @@ describe('platform overview layout guardrails', () => {
});
it('keeps Mail Gateway drawer tables prioritized for narrow inline details', () => {
expect(proxmoxMailGatewayDrawerSource).toContain('platform-table min-w-0 table-fixed text-xs');
expect(proxmoxMailGatewayDrawerSource).toContain(
'<PlatformDetailTable class="min-w-0 table-fixed text-xs">',
);
expect(sharedPlatformPageSource).toContain(
'<Table {...props} class={getPlatformTableClass(props.class)} />',
);
expect(proxmoxMailGatewayDrawerSource).toContain(
'platform-table-mobile-w-10 platform-table-narrow-hidden md:w-[15%]',
);
@@ -22,7 +22,14 @@ import { type FilterOption as PlatformTableFilterOption } from '@/components/sha
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { type SearchInputProps, type SearchInputSuggestion } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
type TableProps,
} from '@/components/shared/Table';
import { getTableSortIndicator } from '@/components/shared/tableSortPresentation';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
@@ -280,6 +287,32 @@ export function getPlatformTableClass(tableClass?: string): string {
.join(' ');
}
// Cardless tables inside drawers and inline detail rows share the exact same
// header, body, density, overflow, and responsive class boundary as page
// tables. Domain-specific columns remain caller-owned; the table chrome does
// not fork merely because it is nested.
export function PlatformDetailTable(props: TableProps) {
return <Table {...props} class={getPlatformTableClass(props.class)} />;
}
export function PlatformDetailTableHeader(props: { children: JSX.Element; class?: string }) {
return (
<TableHeader>
<TableRow class={`${PLATFORM_TABLE_HEADER_ROW_CLASS} ${props.class ?? ''}`.trim()}>
{props.children}
</TableRow>
</TableHeader>
);
}
export function PlatformDetailTableBody(props: { children: JSX.Element; class?: string }) {
return (
<TableBody class={`${PLATFORM_TABLE_BODY_CLASS} ${props.class ?? ''}`.trim()}>
{props.children}
</TableBody>
);
}
export type PlatformTableShellProps = {
title?: JSX.Element;
actions?: JSX.Element;
@@ -296,13 +329,11 @@ export function PlatformTableShell(props: PlatformTableShellProps) {
return (
<TableCard class={props.cardClass ?? PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title} actions={props.actions} />
<Table class={getPlatformTableClass(props.tableClass)} wrapperClass={props.tableWrapperClass}>
<PlatformDetailTable class={props.tableClass} wrapperClass={props.tableWrapperClass}>
{props.colgroup}
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>{props.header}</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>{props.body}</TableBody>
</Table>
<PlatformDetailTableHeader>{props.header}</PlatformDetailTableHeader>
<PlatformDetailTableBody>{props.body}</PlatformDetailTableBody>
</PlatformDetailTable>
{props.footer}
</TableCard>
);
@@ -1,4 +1,4 @@
import { For, Show, createMemo, createSignal, type Accessor, type JSX } from 'solid-js';
import { For, Show, createMemo, type Accessor, type JSX } from 'solid-js';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
@@ -16,7 +16,11 @@ import {
PlatformTableShell,
PlatformWindowedRows,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
createPlatformResourceDetailState,
getPlatformResourceDetailRowInteractionProps,
PlatformResourceDetailToggleButton,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { PBSBackup } from '@/types/api';
import type { Resource, ResourcePBSDatastore } from '@/types/resource';
import type { StatusIndicatorVariant } from '@/utils/status';
@@ -166,7 +170,7 @@ export function ProxmoxBackupServersTable(props: {
: 'full';
});
const visibleColumns = createMemo(() => getBackupServerColumns(layoutMode()));
const [expandedKey, setExpandedKey] = createSignal<string | null>(null);
const detail = createPlatformResourceDetailState({ idPrefix: 'proxmox-backup-server-detail' });
const columnVisible = (column: BackupServerColumnId) =>
visibleColumns().some((candidate) => candidate.id === column);
@@ -255,11 +259,18 @@ export function ProxmoxBackupServersTable(props: {
<PlatformWindowedRows items={rows} estimatedRowHeight={32}>
{(row) => {
const pct = () => (row.datastore ? usagePercent(row.datastore) : undefined);
const isExpanded = () => expandedKey() === row.key;
const detailRowId = () => `proxmox-backup-server-detail-${row.key}`;
const rowIdentity = { id: row.key };
const isExpanded = () => detail.isExpanded(rowIdentity);
const detailRowId = () => detail.detailRowId(rowIdentity);
return (
<>
<TableRow class="hover:bg-surface-hover">
<TableRow
{...getPlatformResourceDetailRowInteractionProps({
expanded: isExpanded(),
detailRowId: detailRowId(),
onToggle: () => detail.toggle(rowIdentity),
})}
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content truncate font-medium`}
title={[row.serverName, row.datastore?.name].filter(Boolean).join(' · ')}
@@ -269,9 +280,7 @@ export function ProxmoxBackupServersTable(props: {
expanded={isExpanded()}
resourceLabel={row.serverName}
controlsId={detailRowId()}
onToggle={() =>
setExpandedKey((current) => (current === row.key ? null : row.key))
}
onToggle={() => detail.toggle(rowIdentity)}
/>
<div class="min-w-0 truncate" title={row.serverName}>
{row.serverName}
@@ -5,17 +5,11 @@ import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
formatPlatformTableIntegerValue,
formatPlatformTablePercentValue,
getPlatformTableCellClassForKind,
@@ -158,37 +152,35 @@ export const ProxmoxCephClusterDrawer: Component<{
when={pools().length > 0}
fallback={<p class="text-xs text-muted">No pools reported.</p>}
>
<Table class="platform-table min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[24%]`}
>
Pool
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Obj" full="Objects" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Store" full="Stored" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Avail" full="Available" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[20%]`}
>
Used
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[24%]`}
>
Pool
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Obj" full="Objects" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Store" full="Stored" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-20 md:w-[20%]`}
>
<PlatformResponsiveTableLabel compact="Avail" full="Available" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[20%]`}
>
Used
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={pools()}>
{(pool, index) => {
const poolKey = () => pool.name || `pool-${index()}`;
@@ -299,8 +291,8 @@ export const ProxmoxCephClusterDrawer: Component<{
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Show>
</Card>
@@ -315,20 +307,18 @@ export const ProxmoxCephClusterDrawer: Component<{
when={services().length > 0}
fallback={<p class="text-xs text-muted">No services reported.</p>}
>
<Table class="platform-table min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Service</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
<PlatformResponsiveTableLabel compact="Up" full="Running" />
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead class={getPlatformTableHeadClassForKind('name')}>Service</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
<PlatformResponsiveTableLabel compact="Up" full="Running" />
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Total
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={services()}>
{(svc) => {
const cls = classifyService(svc);
@@ -366,8 +356,8 @@ export const ProxmoxCephClusterDrawer: Component<{
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Show>
</Card>
</div>
@@ -27,7 +27,10 @@ import {
PlatformWindowedRows,
withPlatformStatusCounts,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
getPlatformResourceDetailRowInteractionProps,
PlatformResourceDetailToggleButton,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import type { Resource, ResourceCephServiceMeta } from '@/types/resource';
import { ProxmoxCephClusterDrawer } from './ProxmoxCephClusterDrawer';
@@ -413,18 +416,11 @@ export const ProxmoxCephTable: Component<{
return (
<>
<TableRow
class={`cursor-pointer hover:bg-surface-hover ${
isOpen() ? 'bg-surface-hover' : ''
}`}
onClick={() => toggleSelected(cluster.id)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleSelected(cluster.id);
}}
aria-controls={isOpen() ? detailRowId() : undefined}
aria-expanded={isOpen()}
tabIndex={0}
{...getPlatformResourceDetailRowInteractionProps({
expanded: isOpen(),
detailRowId: detailRowId(),
onToggle: () => toggleSelected(cluster.id),
})}
>
<TableCell
class={getPlatformTableCellClassForKind('name')}
@@ -7,10 +7,16 @@ import {
formatPlatformTableBytesValue,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
PlatformTableEmptyState,
PlatformTableShell,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
getPlatformResourceDetailRowInteractionProps,
PlatformResourceDetailToggleButton,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { StatusIndicatorVariant } from '@/utils/status';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
@@ -306,7 +312,14 @@ export function ProxmoxCoverageTable(props: {
const detailRowId = () => `proxmox-coverage-evidence-${row.key}`;
return (
<>
<TableRow class="hover:bg-surface-hover" data-proxmox-backup-row="coverage">
<TableRow
{...getPlatformResourceDetailRowInteractionProps({
expanded: isExpanded(),
detailRowId: detailRowId(),
onToggle: () => props.onToggleExpand(row.key),
})}
data-proxmox-backup-row="coverage"
>
<TableCell
class={`${getPlatformTableCellClassForKind('name')} text-base-content`}
>
@@ -564,105 +577,119 @@ export function ProxmoxCoverageTable(props: {
</span>
</Show>
</div>
<table class="w-full text-[11px]">
<thead>
<tr class="bg-surface-alt text-muted">
<th class="px-2 py-0.5 text-left font-medium">Source</th>
<Show
when={isCoverageEvidenceColumnVisible(
layoutMode(),
'location',
)}
<PlatformDetailTable class="table-fixed text-[11px]">
<PlatformDetailTableHeader>
<TableHead class={getPlatformTableHeadClassForKind('name')}>
Source
</TableHead>
<Show
when={isCoverageEvidenceColumnVisible(layoutMode(), 'location')}
>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
Location
</TableHead>
</Show>
<TableHead
class={getPlatformTableHeadClassForKind('numeric-value')}
>
{PROXMOX_BACKUP_COLUMN_LABELS.created}
</TableHead>
<Show
when={isCoverageEvidenceColumnVisible(layoutMode(), 'size')}
>
<TableHead
class={getPlatformTableHeadClassForKind('numeric-value')}
>
<th class="px-2 py-0.5 text-left font-medium">Location</th>
</Show>
<th class="px-2 py-0.5 text-right font-medium">
{PROXMOX_BACKUP_COLUMN_LABELS.created}
</th>
<Show
when={isCoverageEvidenceColumnVisible(layoutMode(), 'size')}
>
<th class="px-2 py-0.5 text-right font-medium">Size</th>
</Show>
<th class="px-2 py-0.5 text-left font-medium">State</th>
<Show
when={isCoverageEvidenceColumnVisible(
layoutMode(),
'details',
)}
>
<th class="px-2 py-0.5 text-left font-medium">
{PROXMOX_BACKUP_COLUMN_LABELS.details}
</th>
</Show>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
Size
</TableHead>
</Show>
<TableHead class={getPlatformTableHeadClassForKind('badge')}>
State
</TableHead>
<Show
when={isCoverageEvidenceColumnVisible(layoutMode(), 'details')}
>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
{PROXMOX_BACKUP_COLUMN_LABELS.details}
</TableHead>
</Show>
</PlatformDetailTableHeader>
<PlatformDetailTableBody class="divide-border-subtle">
<For each={evidence()}>
{(artifact) => (
<tr class="hover:bg-surface-hover">
<td class="px-2 py-1">
<TableRow>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<ArtifactSourceBadge artifact={artifact} />
</td>
</TableCell>
<Show
when={isCoverageEvidenceColumnVisible(
layoutMode(),
'location',
)}
>
<td class="px-2 py-1 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[18rem] truncate"
title={artifact.location}
>
{artifact.location}
</span>
</td>
</TableCell>
</Show>
<td class="px-2 py-1 text-right text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<ProxmoxBackupAgeText artifact={artifact} />
</td>
</TableCell>
<Show
when={isCoverageEvidenceColumnVisible(
layoutMode(),
'size',
)}
>
<td class="px-2 py-1 text-right tabular-nums text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} tabular-nums text-base-content`}
>
<Show
when={artifact.size && artifact.size > 0}
fallback={<span class="text-muted">No size</span>}
>
{formatPlatformTableBytesValue(artifact.size)}
</Show>
</td>
</TableCell>
</Show>
<td class="px-2 py-1">
<TableCell
class={getPlatformTableCellClassForKind('badge')}
>
<ArtifactStateBadge
artifact={artifact}
label={artifactStateLabel(artifact)}
/>
</td>
</TableCell>
<Show
when={isCoverageEvidenceColumnVisible(
layoutMode(),
'details',
)}
>
<td class="px-2 py-1 text-base-content">
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[24rem] truncate"
title={artifact.detail}
>
{artifact.detail || '—'}
</span>
</td>
</TableCell>
</Show>
</tr>
</TableRow>
)}
</For>
</tbody>
</table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</div>
</Show>
</InlineDetailTableRow>
@@ -3,17 +3,11 @@ import { Card } from '@/components/shared/Card';
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PlatformDetailTable,
PlatformDetailTableBody,
PlatformDetailTableHeader,
formatPlatformTableIntegerValue,
formatPlatformTablePercentValue,
formatPlatformTableUptimeValue,
@@ -483,42 +477,40 @@ export const ProxmoxMailGatewayDrawer: Component<{
when={nodes().length > 0}
fallback={<p class="text-xs text-muted">No cluster nodes reported.</p>}
>
<Table class="platform-table min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[24%]`}
>
Node
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[14%]`}
>
Role
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Up" full="Uptime" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Ld" full="Load" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[15%]`}
>
<PlatformResponsiveTableLabel compact="Q" full="Queue" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 platform-table-narrow-hidden md:w-[15%]`}
>
<PlatformResponsiveTableLabel compact="Old" full="Oldest" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[24%]`}
>
Node
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} platform-table-mobile-w-15 md:w-[14%]`}
>
Role
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Up" full="Uptime" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[16%]`}
>
<PlatformResponsiveTableLabel compact="Ld" full="Load" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[15%]`}
>
<PlatformResponsiveTableLabel compact="Q" full="Queue" />
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-10 platform-table-narrow-hidden md:w-[15%]`}
>
<PlatformResponsiveTableLabel compact="Old" full="Oldest" />
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={nodes()}>
{(node) => {
const cls = classifyNode(node);
@@ -580,8 +572,8 @@ export const ProxmoxMailGatewayDrawer: Component<{
);
}}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Show>
</Card>
@@ -598,37 +590,35 @@ export const ProxmoxMailGatewayDrawer: Component<{
when={topDomains().length > 0}
fallback={<p class="text-xs text-muted">No domain stats reported.</p>}
>
<Table class="platform-table min-w-0 table-fixed text-xs">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[28%]`}
>
Domain
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Mail
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Spam
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Virus
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[30%]`}
>
Bytes
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<PlatformDetailTable class="min-w-0 table-fixed text-xs">
<PlatformDetailTableHeader>
<TableHead
class={`${getPlatformTableHeadClassForKind('name')} platform-table-mobile-w-30 md:w-[28%]`}
>
Domain
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Mail
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Spam
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[14%]`}
>
Virus
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} platform-table-mobile-w-15 md:w-[30%]`}
>
Bytes
</TableHead>
</PlatformDetailTableHeader>
<PlatformDetailTableBody>
<For each={topDomains()}>
{(domain) => (
<TableRow>
@@ -671,8 +661,8 @@ export const ProxmoxMailGatewayDrawer: Component<{
</TableRow>
)}
</For>
</TableBody>
</Table>
</PlatformDetailTableBody>
</PlatformDetailTable>
</Show>
</Card>
@@ -1,4 +1,4 @@
import { For, Show, createMemo, createSignal, type Component } from 'solid-js';
import { For, Show, createMemo, type Component } from 'solid-js';
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -23,7 +23,11 @@ import {
withPlatformStatusCounts,
} from '@/features/platformPage/sharedPlatformPage';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
createPlatformResourceDetailState,
getPlatformResourceDetailRowInteractionProps,
PlatformResourceDetailToggleButton,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
import { ProxmoxMailGatewayDrawer } from './ProxmoxMailGatewayDrawer';
@@ -84,8 +88,7 @@ export const ProxmoxMailGatewayTable: Component<{
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const [selectedId, setSelectedId] = createSignal<string | null>(null);
const toggleSelected = (id: string) => setSelectedId((current) => (current === id ? null : id));
const detail = createPlatformResourceDetailState({ idPrefix: 'proxmox-mail-gateway-detail' });
const observedWidth = useObservedElementWidth();
const layout = createMemo(() =>
getPlatformTableContainerLayout(observedWidth.width() ?? 1920, [520, 720, 960, 1200]),
@@ -236,17 +239,16 @@ export const ProxmoxMailGatewayTable: Component<{
const name = () => asTrimmedString(instance.name) || instance.id;
const version = () => asTrimmedString(pmg()?.version) || '—';
const indicator = () => getSimpleStatusIndicator(instance.status);
const isOpen = () => selectedId() === instance.id;
const detailRowId = () => `proxmox-mail-gateway-detail-${instance.id}`;
const isOpen = () => detail.isExpanded(instance);
const detailRowId = () => detail.detailRowId(instance);
return (
<>
<TableRow
class={`cursor-pointer hover:bg-surface-hover ${
isOpen() ? 'bg-surface-hover' : ''
}`}
onClick={() => toggleSelected(instance.id)}
aria-controls={isOpen() ? detailRowId() : undefined}
aria-expanded={isOpen()}
{...getPlatformResourceDetailRowInteractionProps({
expanded: isOpen(),
detailRowId: detailRowId(),
onToggle: () => detail.toggle(instance),
})}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex items-center gap-2 min-w-0">
@@ -254,7 +256,7 @@ export const ProxmoxMailGatewayTable: Component<{
expanded={isOpen()}
resourceLabel={name()}
controlsId={detailRowId()}
onToggle={() => toggleSelected(instance.id)}
onToggle={() => detail.toggle(instance)}
/>
<StatusDot
size="sm"
@@ -357,7 +359,7 @@ export const ProxmoxMailGatewayTable: Component<{
>
<ProxmoxMailGatewayDrawer
instanceRow={instance}
onClose={() => setSelectedId(null)}
onClose={() => detail.close(instance)}
/>
</InlineDetailTableRow>
</Show>
@@ -1884,7 +1884,7 @@ describe('frontend resource type boundaries', () => {
expect(swarmServicesDrawerSource).toContain('getSwarmDrawerPresentation');
expect(swarmServicesDrawerSource).toContain('getSwarmServicesEmptyState');
expect(swarmServicesDrawerSource).toContain('getSwarmServicesLoadingState');
expect(swarmServicesDrawerSource).toMatch(/<Table(?:\s|>)/);
expect(swarmServicesDrawerSource).toContain('<PlatformDetailTable');
expect(swarmServicesDrawerSource).not.toContain('<div class="overflow-x-auto">');
expect(swarmServicesDrawerSource).not.toContain('const statusTone =');
expect(swarmServicesDrawerSource).not.toContain('No Swarm cluster detected');
@@ -1900,7 +1900,7 @@ describe('frontend resource type boundaries', () => {
expect(k8sDeploymentsDrawerSource).toContain('getK8sDeploymentsDrawerPresentation');
expect(k8sDeploymentsDrawerSource).toContain('getK8sDeploymentsEmptyState');
expect(k8sDeploymentsDrawerSource).toContain('getK8sDeploymentsLoadingState');
expect(k8sDeploymentsDrawerSource).toMatch(/<Table(?:\s|>)/);
expect(k8sDeploymentsDrawerSource).toContain('<PlatformDetailTable');
expect(k8sDeploymentsDrawerSource).not.toContain('<div class="overflow-x-auto">');
expect(k8sDeploymentPresentationSource).toContain('getAllFilterOptionLabel');
expect(k8sDeploymentsDrawerSource).not.toContain('const statusTone =');
@@ -1929,7 +1929,7 @@ describe('frontend resource type boundaries', () => {
expect(k8sNamespacesDrawerSource).toContain('getK8sNamespacesLoadingState');
expect(k8sNamespacesDrawerSource).toContain('getK8sNamespacesFailureState');
expect(k8sNamespacesDrawerSource).toContain('<StatusDot');
expect(k8sNamespacesDrawerSource).toMatch(/<Table(?:\s|>)/);
expect(k8sNamespacesDrawerSource).toContain('<PlatformDetailTable');
expect(k8sNamespacesDrawerSource).not.toContain('<div class="overflow-x-auto">');
expect(k8sNamespacesDrawerSource).not.toContain('const statusTone =');
expect(k8sNamespacesDrawerSource).not.toContain('Scope Pods and Deployments by namespace');
@@ -2624,9 +2624,12 @@ describe('frontend resource type boundaries', () => {
expect(discoverySettingsFormSource).not.toContain(
'Discovery settings are locked by environment variables.',
);
expect(proxmoxMailGatewayDrawerSource).toMatch(/<Table(?:\s|>)/);
expect(proxmoxMailGatewayDrawerSource).toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(proxmoxMailGatewayDrawerSource).toContain('PLATFORM_TABLE_BODY_CLASS');
expect(proxmoxMailGatewayDrawerSource).toContain('<PlatformDetailTable');
expect(proxmoxMailGatewayDrawerSource).toContain('<PlatformDetailTableHeader>');
expect(proxmoxMailGatewayDrawerSource).toContain('<PlatformDetailTableBody>');
expect(proxmoxMailGatewayDrawerSource).not.toMatch(/<Table(?:\s|>)/);
expect(proxmoxMailGatewayDrawerSource).not.toContain('PLATFORM_TABLE_HEADER_ROW_CLASS');
expect(proxmoxMailGatewayDrawerSource).not.toContain('PLATFORM_TABLE_BODY_CLASS');
expect(proxmoxMailGatewayDrawerSource).toContain('getPlatformTableHeadClassForKind');
expect(proxmoxMailGatewayDrawerSource).toContain('getPlatformTableCellClassForKind');
expect(proxmoxMailGatewayDrawerSource).not.toContain('divide-y divide-border-subtle');