diff --git a/assets/src/css/admin/settings.css b/assets/src/css/admin/settings.css index 75fa13b..43e0434 100644 --- a/assets/src/css/admin/settings.css +++ b/assets/src/css/admin/settings.css @@ -1529,6 +1529,136 @@ border-bottom: 0; } +.perform-plugin-impact { + display: grid; + gap: 24px; +} + +.perform-plugin-impact > *, +.perform-plugin-impact .components-card__body { + min-width: 0; +} + +.perform-plugin-impact__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--perform-color-border); +} + +.perform-plugin-impact__heading h2 { + margin: 0 0 8px; + font-size: 24px; +} + +.perform-plugin-impact__heading p:not(.perform-dashboard-eyebrow), +.perform-plugin-impact__contract p { + margin: 0; + color: var(--perform-color-text-muted); + line-height: 1.5; +} + +.perform-plugin-impact__contract { + padding: 16px 20px; + border-left: 4px solid var(--perform-color-brand); + background: #f6f7f7; +} + +.perform-plugin-impact__contract p { + margin-top: 6px; +} + +.perform-plugin-impact__summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + border-top: 1px solid var(--perform-color-border); + border-bottom: 1px solid var(--perform-color-border); +} + +.perform-plugin-impact__summary > div { + padding: 20px 24px; + border-right: 1px solid var(--perform-color-border); +} + +.perform-plugin-impact__summary > div:first-child { + padding-left: 0; +} + +.perform-plugin-impact__summary > div:last-child { + border-right: 0; +} + +.perform-plugin-impact__summary span, +.perform-plugin-impact__summary small { + display: block; + color: var(--perform-color-text-muted); + font-size: 12px; + font-weight: 600; +} + +.perform-plugin-impact__summary strong { + display: block; + margin: 8px 0 4px; + font-size: 24px; +} + +.perform-plugin-impact__table-wrap { + max-width: 100%; + overflow-x: auto; +} + +.perform-plugin-impact table { + width: 100%; + border-collapse: collapse; + text-align: left; +} + +.perform-plugin-impact th, +.perform-plugin-impact td { + padding: 14px 12px; + border-bottom: 1px solid var(--perform-color-border); + vertical-align: top; +} + +.perform-plugin-impact thead th { + color: var(--perform-color-text-muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.perform-plugin-impact th small { + display: block; + margin-top: 4px; + color: var(--perform-color-text-muted); + font-weight: 400; +} + +.perform-plugin-impact td span { + display: inline-flex; + padding: 3px 8px; + border-radius: 12px; + background: #f6f7f7; + font-size: 11px; + font-weight: 600; +} + +.perform-plugin-impact td span.is-measured { + background: #edfaef; + color: var(--perform-color-success); +} + +.perform-plugin-impact__unavailable { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 24px; + margin: 0; + padding-left: 20px; + color: var(--perform-color-text-muted); +} + @media screen and (max-width: 782px) { .perform-settings-page { @@ -1729,6 +1859,93 @@ border-bottom: 0; } + .perform-plugin-impact__heading { + flex-direction: column; + } + + .perform-plugin-impact__heading .components-button { + justify-content: center; + width: 100%; + } + + .perform-plugin-impact__summary, + .perform-plugin-impact__unavailable { + grid-template-columns: minmax(0, 1fr); + } + + .perform-plugin-impact__summary > div, + .perform-plugin-impact__summary > div:first-child { + padding: 16px 0; + border-right: 0; + border-bottom: 1px solid var(--perform-color-border); + } + + .perform-plugin-impact__summary > div:last-child { + border-bottom: 0; + } + + .perform-plugin-impact__table-wrap { + overflow: visible; + } + + .perform-plugin-impact table, + .perform-plugin-impact tbody, + .perform-plugin-impact tr, + .perform-plugin-impact th, + .perform-plugin-impact td { + display: block; + width: 100%; + } + + .perform-plugin-impact thead { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .perform-plugin-impact tbody tr { + margin-top: 16px; + border: 1px solid var(--perform-color-border); + } + + .perform-plugin-impact tbody tr:first-child { + margin-top: 0; + } + + .perform-plugin-impact tbody th, + .perform-plugin-impact tbody td { + box-sizing: border-box; + padding: 12px 14px; + } + + .perform-plugin-impact tbody th { + background: #f6f7f7; + } + + .perform-plugin-impact tbody td { + display: grid; + grid-template-columns: minmax(92px, 0.45fr) minmax(0, 1fr); + gap: 12px; + } + + .perform-plugin-impact tbody td::before { + content: attr(data-label); + color: var(--perform-color-text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .perform-plugin-impact tbody tr > :last-child { + border-bottom: 0; + } + .perform-admin-monitor__table-wrap { overflow: visible; } diff --git a/assets/src/js/admin/PluginImpactPanel.jsx b/assets/src/js/admin/PluginImpactPanel.jsx new file mode 100644 index 0000000..f2b30de --- /dev/null +++ b/assets/src/js/admin/PluginImpactPanel.jsx @@ -0,0 +1,186 @@ +import { Button, Card, CardBody, CardHeader } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; + +const SETTINGS = window.performwpSettings || {}; + +const PluginImpactPanel = ( { report = SETTINGS.pluginImpact || {}, onNavigate } ) => { + const items = report.items || []; + const audit = report.adminAssetAudit || {}; + const inventoryReady = 'ready' === report.status; + let primaryAction = ( + + ); + if ( ! inventoryReady ) { + primaryAction = ( + + ); + } else if ( ! audit.enabled ) { + primaryAction = ( + + ); + } + + return ( +
+
+
+

{ __( 'Measured local evidence', 'perform' ) }

+

{ __( 'Plugin impact', 'perform' ) }

+

+ { __( + 'Connect active plugins to admin asset pressure that Perform has actually observed on this site.', + 'perform' + ) } +

+
+ { primaryAction } +
+ +
+ { __( 'Evidence, not a plugin ranking', 'perform' ) } +

+ { __( + 'Asset presence does not prove a plugin is slow or that its files are safe to disable. Perform does not attribute query, callback, or memory cost to a plugin without a dedicated measurement contract.', + 'perform' + ) } +

+
+ +
+
+ { __( 'Active plugins', 'perform' ) } + { report.activeCount ?? items.length } + { __( 'From site inventory', 'perform' ) } +
+
+ { __( 'Sampled admin screens', 'perform' ) } + { audit.sampledScreens ?? 0 } + + { audit.enabled ? __( 'Audit enabled', 'perform' ) : __( 'Audit not enabled', 'perform' ) } + +
+
+ { __( 'Plugin signals', 'perform' ) } + { items.filter( ( item ) => item.hasEvidence ).length } + { __( 'High-confidence source matches', 'perform' ) } +
+
+ + { ! inventoryReady && ( + + +

{ __( 'Site inventory is required', 'perform' ) }

+

+ { __( + 'Generate the private site inventory before matching measured handles to active plugins.', + 'perform' + ) } +

+
+
+ ) } + { inventoryReady && 0 === items.length && ( + + +

{ __( 'No active plugins are available in the bounded inventory', 'perform' ) }

+

{ __( 'Refresh Site Inventory if the active plugin list has changed.', 'perform' ) }

+
+
+ ) } + + { inventoryReady && items.length > 0 && ( + + +
+

{ __( 'Observed admin asset presence', 'perform' ) }

+

+ { sprintf( + /* translators: %d: number of sampled admin screens. */ + __( + 'Based on %d sampled admin screens. Zero means not observed in this sample, not zero impact.', + 'perform' + ), + audit.sampledScreens ?? 0 + ) } +

+
+
+ +
+ + + + + + + + + + + + { items.map( ( item ) => ( + + + + + + + + ) ) } + +
{ __( 'Plugin', 'perform' ) }{ __( 'Screens', 'perform' ) }{ __( 'Scripts', 'perform' ) }{ __( 'Styles', 'perform' ) }{ __( 'Evidence', 'perform' ) }
+ { item.name } + + { item.slug } + { item.version ? ` · ${ item.version }` : '' } + + + { item.adminAssets?.screenCount || 0 } + + { item.adminAssets?.scriptCount || 0 } + + { item.adminAssets?.styleCount || 0 } + + + { item.hasEvidence + ? __( 'Measured locally', 'perform' ) + : __( 'Not observed', 'perform' ) } + +
+
+
+
+ ) } + + + +
+

{ __( 'Not attributed in this release', 'perform' ) }

+

+ { __( + 'These signals need dedicated instrumentation before Perform can connect them to a plugin reliably.', + 'perform' + ) } +

+
+
+ +
    +
  • { __( 'Frontend asset ownership and request context', 'perform' ) }
  • +
  • { __( 'Database query contribution by plugin', 'perform' ) }
  • +
  • { __( 'Callback execution time by plugin', 'perform' ) }
  • +
  • { __( 'Memory contribution by plugin', 'perform' ) }
  • +
+
+
+
+ ); +}; + +export default PluginImpactPanel; diff --git a/assets/src/js/admin/PluginImpactPanel.test.jsx b/assets/src/js/admin/PluginImpactPanel.test.jsx new file mode 100644 index 0000000..0f3271e --- /dev/null +++ b/assets/src/js/admin/PluginImpactPanel.test.jsx @@ -0,0 +1,62 @@ +/* eslint-env jest */ + +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; + +const PluginImpactPanel = require( './PluginImpactPanel' ).default; + +describe( 'PluginImpactPanel', () => { + it( 'requires inventory before presenting plugin claims', () => { + const navigate = jest.fn(); + render( + + ); + expect( screen.getByText( 'Site inventory is required' ) ).toBeInTheDocument(); + fireEvent.click( screen.getByRole( 'button', { name: 'Generate site inventory' } ) ); + expect( navigate ).toHaveBeenCalledWith( 'inventory' ); + } ); + + it( 'separates measured asset presence from unavailable attribution', () => { + render( + + ); + expect( screen.getByText( 'Evidence, not a plugin ranking' ) ).toBeInTheDocument(); + expect( screen.getByText( /Zero means not observed in this sample/ ) ).toBeInTheDocument(); + expect( screen.getByText( 'Database query contribution by plugin' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Callback execution time by plugin' ) ).toBeInTheDocument(); + expect( screen.getAllByText( 'Measured locally' ) ).toHaveLength( 1 ); + } ); + + it( 'handles an empty active-plugin inventory', () => { + render( + + ); + expect( screen.getByText( 'No active plugins are available in the bounded inventory' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/assets/src/js/admin/SettingsApp.jsx b/assets/src/js/admin/SettingsApp.jsx index 52d094f..20545e8 100644 --- a/assets/src/js/admin/SettingsApp.jsx +++ b/assets/src/js/admin/SettingsApp.jsx @@ -16,6 +16,7 @@ const SYSTEM_HEALTH = SETTINGS.systemHealth || {}; const ADMIN_PERFORMANCE = SETTINGS.adminPerformance || {}; const CRON_PRESSURE = SETTINGS.cronPressure || {}; const ADMIN_ASSET_AUDIT = SETTINGS.adminAssetAudit || {}; +const PLUGIN_IMPACT = SETTINGS.pluginImpact || {}; const TAB_KEYS = [ 'dashboard', ...Object.keys( SETTINGS_TABS ) ]; const normalizeTab = ( tab ) => ( TAB_KEYS.includes( tab ) ? tab : 'dashboard' ); @@ -185,6 +186,7 @@ const SettingsApp = () => { adminPerformance={ ADMIN_PERFORMANCE } cronPressure={ CRON_PRESSURE } adminAssetAudit={ ADMIN_ASSET_AUDIT } + pluginImpact={ PLUGIN_IMPACT } activeTab={ activeTab } onTabChange={ handleTabChange } fieldValues={ fieldValues } @@ -199,6 +201,7 @@ const SettingsApp = () => { 'admin-monitor', 'scheduled-tasks', 'admin-assets', + 'plugin-impact', ].includes( activeTab ) && (