mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Canonicalize platform frontend contracts
This commit is contained in:
+210
@@ -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`
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
+18
-16
@@ -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');
|
||||
});
|
||||
});
|
||||
+51
@@ -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();
|
||||
});
|
||||
});
|
||||
+14
-4
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user