Plugin Directory

Changeset 3469978


Ignore:
Timestamp:
02/26/2026 07:56:02 AM (5 weeks ago)
Author:
replikon
Message:

Fixes and small changes + new events for User Events feature.

Location:
divewp-boost-site-performance/trunk
Files:
15 edited

Legend:

Unmodified
Added
Removed
  • divewp-boost-site-performance/trunk/README.txt

    r3467419 r3469978  
    55Tested up to: 6.9
    66Requires PHP: 7.2
    7 Stable tag: 2.3.1
     7Stable tag: 2.3.3
    88License: GPLv2 or later
    99License URI: http://www.gnu.org/licenses/gpl-2.0.html
     
    269269== Changelog ==
    270270
    271 = 2.3.0 =
     271
     272= 2.3.2 =
     273* **FIXED**: Plugin Check – nonce verification for theme/plugin file edit logging
     274* **FIXED**: Plugin Check – PreparedSQL and DirectQuery phpcs ignores for DB/migration code
     275* **FIXED**: Cron execution log – RUNS column now shows correct per-hook totals (SQL GROUP BY)
     276* **FIXED**: Hook history modal – Total Runs uses authoritative backend count, not capped at 50
     277* **FIXED**: Hook filter – exact match instead of partial LIKE for accurate counts
     278* **FIXED**: Clear logs – result modal shows success count or "No logs older than X days"
     279* **FIXED**: Clear older button – prompts to select time period when none selected
     280* **FIXED**: Result modal close – uses top-right X only (consistent with other cron modals)
     281* **FIXED**: PHPCS – PreparedSQL.InterpolatedNotPrepared warnings in DB access layer
     282* **IMPROVED**: User Events – structured context (WHO/WHERE/VIA-WHAT/WHEN)
     283* Added: Additive schema migration for user_events (actor_snapshot, channel, source_context, target_context, request_metadata)
     284* Added: Event details modal – click any event row to see Who, Via, When, Details, and technical context
     285* Added: Structured logging – who initiated, where (screen/route), via what (admin UI, AJAX, REST app password)
     286* Fixed: Timeline "Today" label – now correctly compares with site timezone instead of always showing "Today" for first group
     287* Fixed: Removed inline styles from User Events markup (moved to user-events.css)
     288* Compatibility: Existing events remain visible; new fields are optional with fallbacks. No destructive changes.
     289
     290= 2.3.1 =
    272291* **NEW**: Plugins Management feature
    273292* Added: Plugins Management dashboard – list all installed plugins with status pills (Active, Inactive, Update Available, Up to date)
     
    352371== Upgrade Notice ==
    353372
     373= 2.3.3 =
     374User Events upgrade: structured context (Who/Where/Via/When), event details modal, and timeline fixes. Additive DB migration—existing data preserved. Recommended for all users.
     375
     376= 2.3.2 =
     377Bug fixes for Cron execution log (correct RUNS counts, clear logs feedback, PHPCS compliance). Recommended for all users.
     378
    354379= 2.3.0 =
    355380New Plugins Management feature: view and manage all installed plugins, see update status, and use the Abilities API (divewp/plugins-management) for AI-assisted plugin management. Recommended for all users.
  • divewp-boost-site-performance/trunk/assets/css/features/cron-jobs.css

    r3467366 r3469978  
    943943}
    944944
     945.divewp-modal-status--success {
     946    background: #dcfce7;
     947    color: #166534;
     948}
     949
     950.divewp-modal-status--danger {
     951    background: #fee2e2;
     952    color: #b91c1c;
     953}
     954
     955/* Result modal: notice with status variant */
     956.divewp-modal-result.divewp-modal-status--success {
     957    background: #dcfce7;
     958    border-color: #bbf7d0;
     959    color: #166534;
     960}
     961
     962.divewp-modal-result.divewp-modal-status--success .dashicons {
     963    color: #16a34a;
     964}
     965
     966.divewp-modal-result.divewp-modal-status--danger {
     967    background: #fee2e2;
     968    border-color: #fecaca;
     969    color: #b91c1c;
     970}
     971
     972.divewp-modal-result.divewp-modal-status--danger .dashicons {
     973    color: #dc2626;
     974}
     975
    945976/* Hook name - prominent display */
    946977.divewp-modal-hook {
     
    16221653    height: 14px;
    16231654    opacity: 0.6;
     1655}
     1656
     1657/* ==========================================================================
     1658   Execution Log Retention & Clear Controls
     1659   ========================================================================== */
     1660
     1661.divewp-cron-log-execution-wrap {
     1662    padding: 16px;
     1663}
     1664
     1665.divewp-cron-log-retention-notice {
     1666    display: flex;
     1667    align-items: center;
     1668    gap: 8px;
     1669    padding: 10px 14px;
     1670    background: #e0f2fe;
     1671    border: 1px solid #bae6fd;
     1672    border-radius: 8px;
     1673    color: #075985;
     1674    font-size: 13px;
     1675    margin-bottom: 12px;
     1676}
     1677
     1678.divewp-cron-log-retention-notice .dashicons {
     1679    font-size: 18px;
     1680    width: 18px;
     1681    height: 18px;
     1682    flex-shrink: 0;
     1683}
     1684
     1685.divewp-cron-log-toolbar {
     1686    display: flex;
     1687    align-items: center;
     1688    gap: 8px;
     1689    margin-bottom: 16px;
     1690    flex-wrap: wrap;
     1691}
     1692
     1693.divewp-cron-log-toolbar .divewp-cron-clear-select {
     1694    padding: 6px 12px;
     1695    border: 1px solid #dcdcde;
     1696    border-radius: 4px;
     1697    font-size: 13px;
     1698    background: #fff;
     1699    min-width: 180px;
     1700}
     1701
     1702.divewp-cron-log-toolbar .divewp-cron-clear-apply,
     1703.divewp-cron-log-toolbar .divewp-cron-clear-all {
     1704    font-size: 12px;
     1705    padding: 6px 12px;
     1706    height: auto;
     1707}
     1708
     1709.divewp-cron-log-toolbar .divewp-cron-clear-all {
     1710    color: #d63638;
     1711    border-color: #d63638;
     1712}
     1713
     1714.divewp-cron-log-toolbar .divewp-cron-clear-all:hover {
     1715    background: #d63638;
     1716    color: #fff;
     1717    border-color: #d63638;
    16241718}
    16251719
  • divewp-boost-site-performance/trunk/assets/css/features/user-events.css

    r3278673 r3469978  
    2929    display: flex;
    3030    gap: 10px;
     31}
     32
     33#divewp-refresh-logs {
     34    margin-right: 10px;
    3135}
    3236
     
    240244}
    241245
     246/* Event details modal (centered, matches Cron Jobs / Plugins Management) */
     247.divewp-event-drawer {
     248    position: fixed;
     249    top: 0;
     250    left: 0;
     251    width: 100vw;
     252    height: 100vh;
     253    background: rgba(0, 0, 0, 0.5);
     254    z-index: 100000;
     255    display: flex;
     256    justify-content: center;
     257    align-items: center;
     258    visibility: hidden;
     259    opacity: 0;
     260    transition: visibility 0s 0.3s, opacity 0.3s;
     261}
     262
     263.divewp-event-drawer.open {
     264    visibility: visible;
     265    opacity: 1;
     266    transition: visibility 0s, opacity 0.3s;
     267}
     268
     269.divewp-event-drawer__overlay {
     270    position: absolute;
     271    top: 0;
     272    right: 0;
     273    bottom: 0;
     274    left: 0;
     275    cursor: pointer;
     276}
     277
     278.divewp-event-drawer__panel {
     279    position: relative;
     280    background: #fff;
     281    border: 2px solid #e5e7eb;
     282    border-radius: 16px;
     283    box-shadow: 0 10px 25px rgba(0, 0, 0, 0.25);
     284    overflow: hidden;
     285    max-width: 600px;
     286    width: 92%;
     287    max-height: 85vh;
     288    display: flex;
     289    flex-direction: column;
     290    animation: divewp-event-modal-fadein 0.3s ease-out;
     291}
     292
     293@keyframes divewp-event-modal-fadein {
     294    from {
     295        opacity: 0;
     296        transform: scale(0.95);
     297    }
     298    to {
     299        opacity: 1;
     300        transform: scale(1);
     301    }
     302}
     303
     304.divewp-event-drawer__header {
     305    display: flex;
     306    align-items: center;
     307    justify-content: space-between;
     308    padding: 16px 20px;
     309    background: #f8f9fa;
     310    border-bottom: 1px solid #e1e5e9;
     311}
     312
     313.divewp-event-drawer__title {
     314    margin: 0;
     315    font-family: 'Literata', serif;
     316    font-size: 18px;
     317    font-weight: 600;
     318    color: #1e293b;
     319}
     320
     321.divewp-event-drawer__close {
     322    display: flex;
     323    align-items: center;
     324    justify-content: center;
     325    width: 32px;
     326    height: 32px;
     327    padding: 0;
     328    background: #fff;
     329    border: 1px solid #dcdcde;
     330    border-radius: 6px;
     331    cursor: pointer;
     332    color: #646970;
     333    transition: all 0.2s;
     334}
     335
     336.divewp-event-drawer__close:hover {
     337    background: #f0f0f1;
     338    color: #d63638;
     339    border-color: #d63638;
     340}
     341
     342.divewp-event-drawer__content {
     343    flex: 1;
     344    overflow-y: auto;
     345    padding: 20px;
     346}
     347
     348/* Event type / action header row inside modal */
     349.divewp-event-type-row {
     350    display: flex;
     351    align-items: center;
     352    justify-content: space-between;
     353    padding-bottom: 14px;
     354    border-bottom: 1px solid #e2e8f0;
     355    margin-bottom: 4px;
     356}
     357
     358.divewp-event-type-row__label {
     359    font-size: 14px;
     360    font-weight: 600;
     361    color: #1e293b;
     362}
     363
     364.divewp-event-row--clickable {
     365    cursor: pointer;
     366}
     367
     368.divewp-event-row--clickable:hover {
     369    background: #f6f7f7;
     370}
     371
    242372#divewp-refresh-email-logs:hover {
    243373    background-color: #3182ce;
  • divewp-boost-site-performance/trunk/assets/js/divewp-admin.js

    r3448398 r3469978  
    520520    });
    521521
     522    // User Events: open details drawer on row click
     523    $(document).on('click', '.divewp-event-row--clickable', function (e) {
     524        var eventId = $(this).data('event-id');
     525        if (!eventId) return;
     526
     527        var $drawer = $('.divewp-event-drawer');
     528        var $content = $drawer.find('.divewp-event-drawer__content');
     529        $content.html('<p>' + (divewpData.loading || 'Loading...') + '</p>');
     530        $drawer.addClass('open').attr('aria-hidden', 'false');
     531
     532        $.ajax({
     533            url: divewpData.ajaxurl,
     534            type: 'POST',
     535            data: {
     536                action: 'divewp_get_event_details',
     537                nonce: divewpData.nonce,
     538                event_id: eventId
     539            },
     540            success: function (response) {
     541                if (response.success) {
     542                    var d = response.data;
     543                    function esc(s) {
     544                        if (s == null || s === '') return '-';
     545                        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
     546                    }
     547                    var who = (d.actor && (d.actor.user_login || d.actor.user_email)) ? (d.actor.user_login || d.actor.user_email) : '-';
     548                    var desc = (d.event && d.event.description) ? d.event.description : '-';
     549                    var actionToClass = { created: 'success', creation: 'success', login: 'success', activated: 'success', installed: 'success', restored: 'success', approved: 'success', updated: 'info', update: 'info', logout: 'info', edited: 'info', customized: 'info', authenticated: 'info', core_updated: 'info', role_changed: 'info', trashed: 'danger', unpublished: 'warning', deactivated: 'warning', deletion: 'danger', deleted: 'danger', deleted_permanently: 'danger', failed_login: 'warning', file_edited: 'warning', password_reset: 'warning' };
     550                    var statusMap = { success: 'success', danger: 'danger', warning: 'warning', info: 'info' };
     551                    var action = (d.event && d.event.event_action) ? d.event.event_action : '';
     552                    var status = (d.event && d.event.status) ? d.event.status : '';
     553                    var pillClass = (action && actionToClass[action]) ? 'status-pill-' + actionToClass[action] : ((status && statusMap[status]) ? 'status-pill-' + statusMap[status] : 'status-pill-info');
     554
     555                    var html = '<div class="divewp-modal-details">';
     556                    html += '<div class="divewp-event-type-row">';
     557                    html += '<span class="divewp-event-type-row__label">' + esc(d.event_type_label) + '</span>';
     558                    html += '<span class="status-pill ' + pillClass + '">' + esc(d.action_label) + '</span>';
     559                    html += '</div>';
     560                    html += '<div class="divewp-modal-grid">';
     561                    html += '<div class="divewp-modal-item">';
     562                    html += '<div class="divewp-modal-item__icon"><span class="dashicons dashicons-admin-users"></span></div>';
     563                    html += '<div class="divewp-modal-item__content">';
     564                    html += '<span class="divewp-modal-item__label">' + (divewpData.eventDetailsWho || 'Who') + '</span>';
     565                    html += '<span class="divewp-modal-item__value">' + esc(who) + '</span>';
     566                    html += '</div></div>';
     567                    html += '<div class="divewp-modal-item">';
     568                    html += '<div class="divewp-modal-item__icon"><span class="dashicons dashicons-admin-site"></span></div>';
     569                    html += '<div class="divewp-modal-item__content">';
     570                    html += '<span class="divewp-modal-item__label">' + (divewpData.eventDetailsVia || 'Via') + '</span>';
     571                    html += '<span class="divewp-modal-item__value">' + esc(d.channel) + '</span>';
     572                    html += '</div></div>';
     573                    html += '<div class="divewp-modal-item">';
     574                    html += '<div class="divewp-modal-item__icon"><span class="dashicons dashicons-calendar-alt"></span></div>';
     575                    html += '<div class="divewp-modal-item__content">';
     576                    html += '<span class="divewp-modal-item__label">' + (divewpData.eventDetailsWhen || 'When') + '</span>';
     577                    html += '<span class="divewp-modal-item__value">' + esc(d.created_at_local) + '</span>';
     578                    html += '</div></div>';
     579                    html += '<div class="divewp-modal-item">';
     580                    html += '<div class="divewp-modal-item__icon"><span class="dashicons dashicons-media-text"></span></div>';
     581                    html += '<div class="divewp-modal-item__content">';
     582                    html += '<span class="divewp-modal-item__label">' + (divewpData.eventDetailsWhat || 'Details') + '</span>';
     583                    html += '<span class="divewp-modal-item__value">' + esc(desc) + '</span>';
     584                    html += '</div></div>';
     585                    html += '</div>';
     586                    if (d.source_context && Object.keys(d.source_context).length) {
     587                        html += '<div class="divewp-modal-args">';
     588                        html += '<div class="divewp-modal-args__header">';
     589                        html += '<span class="dashicons dashicons-location"></span>';
     590                        html += '<span>' + (divewpData.eventDetailsWhere || 'Context') + '</span>';
     591                        html += '</div>';
     592                        html += '<pre class="divewp-modal-args__code">' + esc(JSON.stringify(d.source_context, null, 2)) + '</pre>';
     593                        html += '</div>';
     594                    }
     595                    if (d.target_context && Object.keys(d.target_context).length) {
     596                        html += '<div class="divewp-modal-args">';
     597                        html += '<div class="divewp-modal-args__header">';
     598                        html += '<span class="dashicons dashicons-editor-code"></span>';
     599                        html += '<span>' + (divewpData.eventDetailsTarget || 'Target') + '</span>';
     600                        html += '</div>';
     601                        html += '<pre class="divewp-modal-args__code">' + esc(JSON.stringify(d.target_context, null, 2)) + '</pre>';
     602                        html += '</div>';
     603                    }
     604                    html += '</div>';
     605                    $content.html(html);
     606                } else {
     607                    $content.html('<p>' + (response.data && response.data.message ? response.data.message : 'Failed to load event details.') + '</p>');
     608                }
     609            },
     610            error: function () {
     611                $content.html('<p>' + (divewpData.error || 'Failed to load event details.') + '</p>');
     612            }
     613        });
     614    });
     615
     616    $(document).on('click', '.divewp-event-drawer__close, .divewp-event-drawer__overlay', function (e) {
     617        e.preventDefault();
     618        $('.divewp-event-drawer').removeClass('open').attr('aria-hidden', 'true');
     619    });
     620
     621    $(document).on('keydown', function (e) {
     622        if (e.key === 'Escape' && $('.divewp-event-drawer').hasClass('open')) {
     623            $('.divewp-event-drawer').removeClass('open').attr('aria-hidden', 'true');
     624        }
     625    });
     626
    522627    // Handle new feature highlights
    523628    (function () {
  • divewp-boost-site-performance/trunk/assets/js/divewp-cron-jobs.js

    r3448398 r3469978  
    181181                    self.loadMoreWpCronEvents();
    182182                }
     183            });
     184
     185            // Execution log clear controls
     186            $(document).on('click', '.divewp-cron-clear-apply', function(e) {
     187                e.preventDefault();
     188                self.handleClearLogsOlderThan();
     189            });
     190            $(document).on('click', '.divewp-cron-clear-all', function(e) {
     191                e.preventDefault();
     192                self.handleClearAllLogs();
    183193            });
    184194        },
     
    647657            var self = this;
    648658            var $panel = $('.divewp-cron-panel[data-panel="execution-log"]');
     659            var retentionDays = (divewpCronData && divewpCronData.retention_days) ? divewpCronData.retention_days : 30;
     660            var retentionText = (this.config.strings.retentionNotice || 'Logs are auto-cleaned daily. Retention: %d days.').replace('%d', retentionDays);
    649661
    650662            if (!hooks || hooks.length === 0) {
    651                 $panel.html('<div class="divewp-cron-empty"><span class="dashicons dashicons-media-text"></span><p>' + this.config.strings.noLogs + '</p></div>');
    652                 return;
    653             }
    654 
    655             var html = '<div class="divewp-cron-log-grouped">';
     663                var emptyHtml = '<div class="divewp-cron-log-execution-wrap">';
     664                emptyHtml += '<div class="divewp-cron-log-retention-notice">';
     665                emptyHtml += '<span class="dashicons dashicons-info"></span>';
     666                emptyHtml += '<span>' + this.escapeHtml(retentionText) + '</span>';
     667                emptyHtml += '</div>';
     668                emptyHtml += '<div class="divewp-cron-log-toolbar">';
     669                emptyHtml += '<select class="divewp-cron-clear-select">';
     670                emptyHtml += '<option value="">' + this.escapeHtml(this.config.strings.clearLogsOlderThan || 'Clear logs older than...') + '</option>';
     671                emptyHtml += '<option value="7">7 days</option>';
     672                emptyHtml += '<option value="30">30 days</option>';
     673                emptyHtml += '<option value="90">90 days</option>';
     674                emptyHtml += '</select>';
     675                emptyHtml += '<button type="button" class="button divewp-cron-clear-apply">' + this.escapeHtml(this.config.strings.clearOlder || 'Clear older') + '</button>';
     676                emptyHtml += '<button type="button" class="button divewp-cron-clear-all">' + this.escapeHtml(this.config.strings.clearAllLogs || 'Clear All Logs') + '</button>';
     677                emptyHtml += '</div>';
     678                emptyHtml += '<div class="divewp-cron-empty"><span class="dashicons dashicons-media-text"></span><p>' + this.config.strings.noLogs + '</p></div>';
     679                emptyHtml += '</div>';
     680                $panel.html(emptyHtml);
     681                return;
     682            }
     683
     684            var html = '<div class="divewp-cron-log-execution-wrap">';
     685            html += '<div class="divewp-cron-log-retention-notice">';
     686            html += '<span class="dashicons dashicons-info"></span>';
     687            html += '<span>' + this.escapeHtml(retentionText) + '</span>';
     688            html += '</div>';
     689            html += '<div class="divewp-cron-log-toolbar">';
     690            html += '<select class="divewp-cron-clear-select">';
     691            html += '<option value="">' + this.escapeHtml(this.config.strings.clearLogsOlderThan || 'Clear logs older than...') + '</option>';
     692            html += '<option value="7">7 days</option>';
     693            html += '<option value="30">30 days</option>';
     694            html += '<option value="90">90 days</option>';
     695            html += '</select>';
     696            html += '<button type="button" class="button divewp-cron-clear-apply">' + this.escapeHtml(this.config.strings.clearOlder || 'Clear older') + '</button>';
     697            html += '<button type="button" class="button divewp-cron-clear-all">' + this.escapeHtml(this.config.strings.clearAllLogs || 'Clear All Logs') + '</button>';
     698            html += '</div>';
     699            html += '<div class="divewp-cron-log-grouped">';
    656700            html += '<div class="divewp-cron-log-header">';
    657701            html += '<div class="divewp-cron-log-header__check"><input type="checkbox" class="divewp-cron-select-all"></div>';
     
    681725            }
    682726
    683             html += '</div>';
     727            html += '</div></div>';
    684728            $panel.html(html);
    685729            this.updateBulkActions();
     
    10841128
    10851129        /**
     1130         * Clear execution logs older than selected days
     1131         */
     1132        handleClearLogsOlderThan: function() {
     1133            var self = this;
     1134            var days = parseInt($('.divewp-cron-clear-select').val(), 10);
     1135            if (!days || days <= 0) {
     1136                alert(this.config.strings.errorSelectDays || 'Please select a time period first.');
     1137                return;
     1138            }
     1139            var msg = (this.config.strings.confirmClearOlderThan || 'Delete execution logs older than %d days?').replace('%d', days);
     1140            if (!confirm(msg)) {
     1141                return;
     1142            }
     1143            $('.divewp-cron-clear-apply').prop('disabled', true);
     1144            $.ajax({
     1145                url: this.config.ajaxurl,
     1146                type: 'POST',
     1147                data: {
     1148                    action: 'divewp_cron_clear_logs',
     1149                    nonce: this.config.nonce,
     1150                    days: days
     1151                },
     1152                success: function(response) {
     1153                    $('.divewp-cron-clear-apply').prop('disabled', false);
     1154                    $('.divewp-cron-clear-select').val('');
     1155                    var msg = response.success ? (response.data.message || '') : (response.data.message || self.config.strings.error);
     1156                    self.showResultModal(msg, response.success ? 'success' : 'error');
     1157                    if (response.success && self.state.currentTab === 'execution-log') {
     1158                        self.loadExecutionLogContent();
     1159                    }
     1160                },
     1161                error: function() {
     1162                    $('.divewp-cron-clear-apply').prop('disabled', false);
     1163                    self.showResultModal(self.config.strings.error, 'error');
     1164                }
     1165            });
     1166        },
     1167
     1168        /**
     1169         * Clear all execution logs
     1170         */
     1171        handleClearAllLogs: function() {
     1172            var self = this;
     1173            if (!confirm(this.config.strings.confirmClearAllLogs || 'Delete ALL execution logs? This cannot be undone.')) {
     1174                return;
     1175            }
     1176            $('.divewp-cron-clear-all').prop('disabled', true);
     1177            $.ajax({
     1178                url: this.config.ajaxurl,
     1179                type: 'POST',
     1180                data: {
     1181                    action: 'divewp_cron_clear_logs',
     1182                    nonce: this.config.nonce,
     1183                    days: 0
     1184                },
     1185                success: function(response) {
     1186                    $('.divewp-cron-clear-all').prop('disabled', false);
     1187                    var msg = response.success ? (response.data.message || '') : (response.data.message || self.config.strings.error);
     1188                    self.showResultModal(msg, response.success ? 'success' : 'error');
     1189                    if (response.success && self.state.currentTab === 'execution-log') {
     1190                        self.loadExecutionLogContent();
     1191                    }
     1192                },
     1193                error: function() {
     1194                    $('.divewp-cron-clear-all').prop('disabled', false);
     1195                    self.showResultModal(self.config.strings.error, 'error');
     1196                }
     1197            });
     1198        },
     1199
     1200        /**
    10861201         * Filter table by search term
    10871202         */
     
    14841599            var self = this;
    14851600
     1601            // Handle close action
     1602            if (action === 'close') {
     1603                this.closeDrawer();
     1604                return;
     1605            }
     1606
    14861607            // Add Task modal actions (not tied to a row)
    14871608            if (action === 'add-cancel') {
     
    17961917
    17971918        /**
    1798          * Show notice
     1919         * Show notice (uses global showNotice if available; otherwise alert for errors only)
    17991920         */
    18001921        showNotice: function(message, type) {
    1801             // Use existing DiveWP notice system if available
    18021922            if (typeof divewpData !== 'undefined' && typeof divewpData.showNotice === 'function') {
    18031923                divewpData.showNotice(message, type);
    18041924                return;
    18051925            }
    1806 
    1807             // Fallback to simple alert
    1808             if (type === 'error') {
     1926            if (type === 'error' && message) {
    18091927                alert('Error: ' + message);
    18101928            }
     1929        },
     1930
     1931        /**
     1932         * Show result modal (drawer) with message - used for clear logs feedback
     1933         */
     1934        showResultModal: function(message, type) {
     1935            if (!message) {
     1936                return;
     1937            }
     1938            var $drawer = $('.divewp-cron-drawer');
     1939            var $title = $drawer.find('.divewp-cron-drawer__title');
     1940            var $content = $drawer.find('.divewp-cron-drawer__content');
     1941            var $footer = $drawer.find('.divewp-cron-drawer__footer');
     1942
     1943            this.state.currentRow = null;
     1944            $title.text(this.config.strings.clearResultTitle || 'Clear Logs Result');
     1945
     1946            var iconClass = type === 'error' ? 'dashicons-warning' : 'dashicons-yes-alt';
     1947            var statusClass = type === 'error' ? 'divewp-modal-status--danger' : 'divewp-modal-status--success';
     1948            var html = '<div class="divewp-modal-details">';
     1949            html += '<div class="divewp-modal-notice divewp-modal-result ' + statusClass + '">';
     1950            html += '<span class="dashicons ' + iconClass + '"></span>';
     1951            html += '<span>' + this.escapeHtml(message) + '</span>';
     1952            html += '</div>';
     1953            html += '</div>';
     1954
     1955            $content.html(html);
     1956            $footer.empty().hide();
     1957            $drawer.addClass('open');
    18111958        },
    18121959
  • divewp-boost-site-performance/trunk/assets/js/divewp-plugins-management.js

    r3467419 r3469978  
    385385        }
    386386
    387         // Update button visibility
     387        // Reset and update button visibility
     388        updateBtn.stop(true, true);
     389        updateBtn.prop('disabled', false);
     390        updateBtn.removeClass('button-success button-danger').addClass('button-primary');
     391        updateBtn.find('.update-label').text(divewpPluginsData.strings.updatePlugin);
     392
    388393        if (hasUpdate) {
    389394            updateBtn.show();
  • divewp-boost-site-performance/trunk/divewp.php

    r3467499 r3469978  
    44 * Plugin URI: https://wordpress.org/plugins/divewp-boost-site-performance/
    55 * Description: Learn WordPress Best Practices Through Your Own Site! Get clear insights about Performance, Security, and Best Practices – explained in plain English.
    6  * Version: 2.3.1
     6 * Version: 2.3.3
    77 * Requires at least: 6.8
    88 * Requires PHP: 7.2
     
    3030
    3131// Define plugin constants first
    32 define('DIVEWP_VERSION', '2.3.0');
     32define('DIVEWP_VERSION', '2.3.3');
    3333define('DIVEWP_PLUGIN_DIR', plugin_dir_path(__FILE__));
    3434define('DIVEWP_PLUGIN_URL', plugin_dir_url(__FILE__));
     
    120120            throw new Exception('Failed to initialize database tables');
    121121        }
    122         update_option('divewp_db_version', '2.0.1');
     122        update_option('divewp_db_version', '2.1.0');
    123123        divewp_debug_log('Database tables created successfully with benchmark support', 'info');
    124124        return true;
     
    171171        if (version_compare($current_db_version, '2.0.1', '<')) {
    172172            if (DiveWP_Database::init_tables()) {
    173                 update_option('divewp_db_version', '2.0.1');
    174                 divewp_debug_log('Database updated to version 2.0.1 with benchmark results table', 'info');
     173                update_option('divewp_db_version', '2.1.0');
     174                divewp_debug_log('Database updated to version 2.1.0', 'info');
     175            }
     176        }
     177        if (version_compare($current_db_version, '2.1.0', '<')) {
     178            if (DiveWP_Database::migrate_user_events_structured_context()) {
     179                update_option('divewp_db_version', '2.1.0');
     180                divewp_debug_log('User events schema migrated to 2.1.0 (structured context)', 'info');
    175181            }
    176182        }
  • divewp-boost-site-performance/trunk/includes/admin/templates/admin-left-sidebar.php

    r3467366 r3469978  
    6262                    <i class="dashicons dashicons-admin-site-alt3"></i>
    6363                    <?php esc_html_e('Hosting Benchmark', 'divewp-boost-site-performance'); ?>
    64                     <span class="new-feature-highlight-pill" data-feature-id="hosting"><?php esc_html_e('NEW', 'divewp-boost-site-performance'); ?></span>
    6564                </li>
    6665                <li data-tab="cron-jobs" data-feature="cron-jobs">
    6766                    <i class="dashicons dashicons-clock"></i>
    6867                    <?php esc_html_e('Cron Job Manager', 'divewp-boost-site-performance'); ?>
    69                     <span class="new-feature-highlight-pill" data-feature-id="cron-jobs"><?php esc_html_e('NEW', 'divewp-boost-site-performance'); ?></span>
    7068                </li>
    7169                <li data-tab="user-events" data-feature="user-events">
    7270                    <i class="dashicons dashicons-groups"></i>
    7371                    <?php esc_html_e('User Events', 'divewp-boost-site-performance'); ?>
    74                     <span class="new-feature-highlight-pill" data-feature-id="user-events"><?php esc_html_e('NEW', 'divewp-boost-site-performance'); ?></span>
    7572                </li>
    7673                <li data-tab="email" data-feature="email-insights">
     
    8178                    <i class="dashicons dashicons-rest-api"></i>
    8279                    <?php esc_html_e('AI Capabilities', 'divewp-boost-site-performance'); ?>
    83                     <span class="new-feature-highlight-pill" data-feature-id="ai-capabilities"><?php esc_html_e('NEW', 'divewp-boost-site-performance'); ?></span>
    8480                </li>
    8581                <li data-tab="plugins-management" data-feature="plugins-management">
    8682                    <i class="dashicons dashicons-admin-plugins"></i>
    8783                    <?php esc_html_e('Plugins Management', 'divewp-boost-site-performance'); ?>
     84                    <span class="new-feature-highlight-pill" data-feature-id="plugins-management"><?php esc_html_e('NEW', 'divewp-boost-site-performance'); ?></span>
    8885                </li>
    8986            </ul>
  • divewp-boost-site-performance/trunk/includes/class-divewp-database.php

    r3448398 r3469978  
    268268            status varchar(20) NOT NULL,
    269269            created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
     270            actor_snapshot text,
     271            channel varchar(50),
     272            source_context text,
     273            target_context text,
     274            request_metadata text,
    270275            PRIMARY KEY  (id)
    271276        ) {$charset_collate};";
     
    411416
    412417    /**
     418     * Migrate user_events table additively for structured context fields (v2.1.0).
     419     * Idempotent: safe to rerun; adds missing columns only.
     420     *
     421     * @since 2.3.3
     422     * @return bool True on success, false on failure
     423     */
     424    public static function migrate_user_events_structured_context() {
     425        global $wpdb;
     426
     427        if (!current_user_can('manage_options')) {
     428            return false;
     429        }
     430
     431        $table_name = $wpdb->prefix . self::$tables['user_events'];
     432
     433        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration table check; caching inappropriate for schema verification
     434        if ($wpdb->get_var($wpdb->prepare(
     435            "SHOW TABLES LIKE %s",
     436            $table_name
     437        )) !== $table_name) {
     438            return true;
     439        }
     440
     441        $charset_collate = $wpdb->get_charset_collate();
     442
     443        // dbDelta adds missing columns; does not drop or rename existing ones
     444        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name and charset are admin-defined
     445        $sql = "CREATE TABLE {$table_name} (
     446            id bigint(20) NOT NULL AUTO_INCREMENT,
     447            event_type varchar(50) NOT NULL,
     448            event_action varchar(50) NOT NULL,
     449            user_id bigint(20) NOT NULL,
     450            description text NOT NULL,
     451            status varchar(20) NOT NULL,
     452            created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
     453            actor_snapshot text,
     454            channel varchar(50),
     455            source_context text,
     456            target_context text,
     457            request_metadata text,
     458            PRIMARY KEY  (id)
     459        ) {$charset_collate};";
     460
     461        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
     462        dbDelta($sql);
     463
     464        if (defined('DIVEWP_DEBUG') && DIVEWP_DEBUG) {
     465            divewp_debug_log(esc_html__('User events migration to v2.1.0 completed', 'divewp-boost-site-performance'), 'info');
     466        }
     467
     468        return true;
     469    }
     470
     471    /**
    413472     * Verify existence of required database tables
    414473     *
  • divewp-boost-site-performance/trunk/includes/class-divewp-db-access.php

    r3448398 r3469978  
    162162     * No caching as this is a write operation requiring immediate effect.
    163163     * Protected by capability checks.
     164     * Supports optional structured context (actor_snapshot, channel, source_context, target_context, request_metadata).
     165     *
     166     * @since 2.3.3
    164167     */
    165168    public function log_event($data) {
    166         if (!current_user_can('manage_options')) {
    167             return false;
    168         }
    169 
    170         global $wpdb;
    171        
    172         // Ensure we store the timestamp in UTC
     169        $is_failed_login = isset($data['event_action']) && $data['event_action'] === 'failed_login'
     170            && isset($data['user_id']) && (int) $data['user_id'] === 0;
     171        if (!$is_failed_login && !current_user_can('manage_options')) {
     172            return false;
     173        }
     174
     175        global $wpdb;
     176
     177        $insert_data = array(
     178            'event_type' => sanitize_text_field($data['event_type']),
     179            'event_action' => sanitize_text_field($data['event_action']),
     180            'user_id' => absint($data['user_id']),
     181            'description' => sanitize_text_field($data['description']),
     182            'status' => sanitize_text_field($data['status']),
     183        );
     184
    173185        $utc_now = new DateTime('now', new DateTimeZone('UTC'));
    174        
    175         // Direct insert required for immediate logging
     186        $insert_data['created_at'] = $utc_now->format('Y-m-d H:i:s');
     187
     188        $format = array('%s', '%s', '%d', '%s', '%s', '%s');
     189
     190        if (isset($data['actor_snapshot'])) {
     191            $insert_data['actor_snapshot'] = wp_json_encode($data['actor_snapshot']);
     192            $format[] = '%s';
     193        }
     194        if (isset($data['channel'])) {
     195            $insert_data['channel'] = sanitize_text_field($data['channel']);
     196            $format[] = '%s';
     197        }
     198        if (isset($data['source_context'])) {
     199            $insert_data['source_context'] = wp_json_encode($data['source_context']);
     200            $format[] = '%s';
     201        }
     202        if (isset($data['target_context'])) {
     203            $insert_data['target_context'] = wp_json_encode($data['target_context']);
     204            $format[] = '%s';
     205        }
     206        if (isset($data['request_metadata'])) {
     207            $insert_data['request_metadata'] = wp_json_encode($data['request_metadata']);
     208            $format[] = '%s';
     209        }
     210
    176211        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Write operation for admin-only logging feature
    177         return $wpdb->insert(
    178             $wpdb->prefix . 'divewp_user_events',
    179             array(
    180                 'event_type' => sanitize_text_field($data['event_type']),
    181                 'event_action' => sanitize_text_field($data['event_action']),
    182                 'user_id' => absint($data['user_id']),
    183                 'description' => sanitize_text_field($data['description']),
    184                 'status' => sanitize_text_field($data['status']),
    185                 'created_at' => $utc_now->format('Y-m-d H:i:s')
    186             ),
    187             array('%s', '%s', '%d', '%s', '%s', '%s')
    188         );
     212        return $wpdb->insert($wpdb->prefix . 'divewp_user_events', $insert_data, $format);
    189213    }
    190214
     
    225249       
    226250        return $results ?: array();
     251    }
     252
     253    /**
     254     * Get a single user event by ID with user login.
     255     * Returns structured fields (actor_snapshot, channel, etc.) when present; null for missing.
     256     *
     257     * @since 2.3.3
     258     * @param int $event_id Event ID
     259     * @return object|null Event object or null if not found
     260     */
     261    public function get_event_by_id($event_id) {
     262        if (!current_user_can('manage_options')) {
     263            return null;
     264        }
     265
     266        $event_id = absint($event_id);
     267        if (!$event_id) {
     268            return null;
     269        }
     270
     271        global $wpdb;
     272        $events_table = esc_sql($this->user_events_table);
     273        $users_table = esc_sql($wpdb->users);
     274
     275        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-only data retrieval
     276        $event = $wpdb->get_row(
     277            $wpdb->prepare(
     278                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names escaped with esc_sql(), not user input
     279                "SELECT e.*, u.user_login FROM $events_table e LEFT JOIN $users_table u ON e.user_id = u.ID WHERE e.id = %d",
     280                $event_id
     281            )
     282        );
     283
     284        return $event ?: null;
    227285    }
    228286
     
    858916       
    859917        if (!empty($hook)) {
    860             $where_parts[] = 'hook LIKE %s';
    861             $prepare_values[] = '%' . $wpdb->esc_like(sanitize_text_field($hook)) . '%';
     918            $where_parts[] = 'hook = %s';
     919            $prepare_values[] = sanitize_text_field($hook);
    862920        }
    863921       
     
    9421000            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped
    9431001            "SELECT COUNT(*) FROM $table"
     1002        );
     1003    }
     1004
     1005    /**
     1006     * Get total cron logs count for a specific hook (exact match).
     1007     *
     1008     * @since 2.2.0
     1009     * @param string $hook Hook name (exact match)
     1010     * @return int Total count for that hook
     1011     */
     1012    public function get_cron_logs_count_for_hook($hook) {
     1013        if (!current_user_can('manage_options')) {
     1014            return 0;
     1015        }
     1016        if (empty($hook)) {
     1017            return 0;
     1018        }
     1019        global $wpdb;
     1020        $table = esc_sql($this->cron_logs_table);
     1021        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin-only monitoring
     1022        return (int) $wpdb->get_var(
     1023            $wpdb->prepare(
     1024                // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() and not user input
     1025                "SELECT COUNT(*) FROM $table WHERE hook = %s",
     1026                sanitize_text_field($hook)
     1027            )
     1028        );
     1029    }
     1030
     1031    /**
     1032     * Get cron logs grouped by hook with accurate per-hook totals.
     1033     * Uses SQL GROUP BY for correct counts regardless of table size.
     1034     *
     1035     * @since 2.2.0
     1036     * @param int    $limit  Max number of distinct hooks to return (by last_run DESC)
     1037     * @param string $status Optional status filter
     1038     * @return array Array of hook summaries with total_runs, success_count, etc.
     1039     */
     1040    public function get_cron_logs_grouped_by_hook($limit = 500, $status = '') {
     1041        if (!current_user_can('manage_options')) {
     1042            return array();
     1043        }
     1044        global $wpdb;
     1045        $limit = absint($limit);
     1046        $table = esc_sql($this->cron_logs_table);
     1047        $status_clause = '';
     1048        $prepare_values = array();
     1049        if (!empty($status)) {
     1050            $status_clause = 'WHERE status = %s';
     1051            $prepare_values[] = sanitize_text_field($status);
     1052        }
     1053        $prepare_values[] = $limit;
     1054        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
     1055        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
     1056        $rows = $wpdb->get_results(
     1057            $wpdb->prepare(
     1058                "SELECT
     1059                    hook,
     1060                    COUNT(*) as total_runs,
     1061                    SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
     1062                    SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count,
     1063                    SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warning_count,
     1064                    SUM(COALESCE(duration_ms, 0)) as total_duration,
     1065                    MIN(duration_ms) as min_duration,
     1066                    MAX(duration_ms) as max_duration,
     1067                    MAX(started_at) as last_run,
     1068                    SUBSTRING_INDEX(GROUP_CONCAT(status ORDER BY started_at DESC SEPARATOR '|'), '|', 1) as last_status
     1069                FROM $table
     1070                $status_clause
     1071                GROUP BY hook
     1072                ORDER BY last_run DESC
     1073                LIMIT %d",
     1074                ...$prepare_values
     1075            ),
     1076            ARRAY_A
     1077        );
     1078        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
     1079        if (empty($rows)) {
     1080            return array();
     1081        }
     1082        $display_format = get_option('date_format') . ' ' . get_option('time_format');
     1083        $grouped = array();
     1084        foreach ($rows as $row) {
     1085            $total_runs = (int) $row['total_runs'];
     1086            $success_count = (int) $row['success_count'];
     1087            $grouped[] = array(
     1088                'hook' => $row['hook'],
     1089                'total_runs' => $total_runs,
     1090                'success_count' => $success_count,
     1091                'error_count' => (int) $row['error_count'],
     1092                'warning_count' => (int) $row['warning_count'],
     1093                'avg_duration_ms' => $total_runs > 0 ? round((float) $row['total_duration'] / $total_runs, 1) : 0,
     1094                'min_duration' => $row['min_duration'] !== null ? (int) $row['min_duration'] : null,
     1095                'max_duration' => $row['max_duration'] !== null ? (int) $row['max_duration'] : null,
     1096                'last_run' => $row['last_run'],
     1097                'last_run_local' => get_date_from_gmt($row['last_run'], $display_format),
     1098                'last_status' => $row['last_status'],
     1099                'success_rate' => $total_runs > 0 ? round(($success_count / $total_runs) * 100, 0) : 0,
     1100            );
     1101        }
     1102        return $grouped;
     1103    }
     1104
     1105    /**
     1106     * Get summary stats for a specific hook (exact match).
     1107     *
     1108     * @since 2.2.0
     1109     * @param string $hook Hook name (exact match)
     1110     * @return array Summary with total_runs, success_count, error_count, etc.
     1111     */
     1112    public function get_cron_log_summary_for_hook($hook) {
     1113        if (!current_user_can('manage_options')) {
     1114            return array(
     1115                'total_runs' => 0,
     1116                'success_count' => 0,
     1117                'error_count' => 0,
     1118                'warning_count' => 0,
     1119                'success_rate' => 0,
     1120                'avg_duration_ms' => 0,
     1121                'min_duration_ms' => null,
     1122                'max_duration_ms' => null,
     1123            );
     1124        }
     1125        if (empty($hook)) {
     1126            return array(
     1127                'total_runs' => 0,
     1128                'success_count' => 0,
     1129                'error_count' => 0,
     1130                'warning_count' => 0,
     1131                'success_rate' => 0,
     1132                'avg_duration_ms' => 0,
     1133                'min_duration_ms' => null,
     1134                'max_duration_ms' => null,
     1135            );
     1136        }
     1137        global $wpdb;
     1138        $table = esc_sql($this->cron_logs_table);
     1139        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
     1140        $row = $wpdb->get_row(
     1141            $wpdb->prepare(
     1142                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is escaped with esc_sql() and not user input
     1143                "SELECT
     1144                    COUNT(*) as total_runs,
     1145                    SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
     1146                    SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error_count,
     1147                    SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warning_count,
     1148                    SUM(COALESCE(duration_ms, 0)) as total_duration,
     1149                    MIN(duration_ms) as min_duration,
     1150                    MAX(duration_ms) as max_duration
     1151                FROM $table
     1152                WHERE hook = %s",
     1153                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
     1154                sanitize_text_field($hook)
     1155            ),
     1156            ARRAY_A
     1157        );
     1158        if (!$row || (int) $row['total_runs'] === 0) {
     1159            return array(
     1160                'total_runs' => 0,
     1161                'success_count' => 0,
     1162                'error_count' => 0,
     1163                'warning_count' => 0,
     1164                'success_rate' => 0,
     1165                'avg_duration_ms' => 0,
     1166                'min_duration_ms' => null,
     1167                'max_duration_ms' => null,
     1168            );
     1169        }
     1170        $total_runs = (int) $row['total_runs'];
     1171        $success_count = (int) $row['success_count'];
     1172        return array(
     1173            'total_runs' => $total_runs,
     1174            'success_count' => $success_count,
     1175            'error_count' => (int) $row['error_count'],
     1176            'warning_count' => (int) $row['warning_count'],
     1177            'success_rate' => round(($success_count / $total_runs) * 100, 0),
     1178            'avg_duration_ms' => round((float) $row['total_duration'] / $total_runs, 1),
     1179            'min_duration_ms' => $row['min_duration'] !== null ? (int) $row['min_duration'] : null,
     1180            'max_duration_ms' => $row['max_duration'] !== null ? (int) $row['max_duration'] : null,
    9441181        );
    9451182    }
  • divewp-boost-site-performance/trunk/includes/class-divewp-main.php

    r3467366 r3469978  
    443443            'nonce' => wp_create_nonce('divewp_nonce'),
    444444            'testEmailFailed' => __('Failed to send test email', 'divewp-boost-site-performance'),
     445            'loading' => __('Loading...', 'divewp-boost-site-performance'),
     446            'error' => __('Failed to load event details.', 'divewp-boost-site-performance'),
     447            'eventDetailsWho' => __('Who', 'divewp-boost-site-performance'),
     448            'eventDetailsVia' => __('Via', 'divewp-boost-site-performance'),
     449            'eventDetailsWhen' => __('When', 'divewp-boost-site-performance'),
     450            'eventDetailsWhat' => __('Details', 'divewp-boost-site-performance'),
     451            'eventDetailsWhere' => __('Context', 'divewp-boost-site-performance'),
     452            'eventDetailsTarget' => __('Target', 'divewp-boost-site-performance'),
    445453            'preloader' => array(
    446454                'minDuration' => 1000,
  • divewp-boost-site-performance/trunk/includes/features/cron-jobs/ajax-handlers.php

    r3448398 r3469978  
    767767
    768768        $db = DiveWP_DB_Access::get_instance();
    769         $logs = $db->get_recent_cron_logs($limit, 0, $status, '');
     769        $hooks = $db->get_cron_logs_grouped_by_hook($limit, $status);
    770770        $total = $db->get_total_cron_logs($status);
    771771        $stats = $db->get_cron_log_stats();
    772         $display_format = get_option('date_format') . ' ' . get_option('time_format');
    773 
    774         // Group logs by hook name
    775         $grouped = array();
    776         foreach ($logs as $log) {
    777             $hook = $log->hook;
    778             if (!isset($grouped[$hook])) {
    779                 $grouped[$hook] = array(
    780                     'hook' => $hook,
    781                     'total_runs' => 0,
    782                     'success_count' => 0,
    783                     'error_count' => 0,
    784                     'warning_count' => 0,
    785                     'total_duration' => 0,
    786                     'min_duration' => null,
    787                     'max_duration' => null,
    788                     'last_run' => null,
    789                     'last_status' => null,
    790                     'trigger_sources' => array(),
    791                 );
    792             }
    793 
    794             $grouped[$hook]['total_runs']++;
    795 
    796             // Count by status
    797             if ($log->status === 'success') {
    798                 $grouped[$hook]['success_count']++;
    799             } elseif ($log->status === 'error') {
    800                 $grouped[$hook]['error_count']++;
    801             } elseif ($log->status === 'warning') {
    802                 $grouped[$hook]['warning_count']++;
    803             }
    804 
    805             // Duration stats
    806             $duration = isset($log->duration_ms) ? (int) $log->duration_ms : 0;
    807             $grouped[$hook]['total_duration'] += $duration;
    808             if ($grouped[$hook]['min_duration'] === null || $duration < $grouped[$hook]['min_duration']) {
    809                 $grouped[$hook]['min_duration'] = $duration;
    810             }
    811             if ($grouped[$hook]['max_duration'] === null || $duration > $grouped[$hook]['max_duration']) {
    812                 $grouped[$hook]['max_duration'] = $duration;
    813             }
    814 
    815             // Track trigger sources
    816             if (!empty($log->trigger_source) && !in_array($log->trigger_source, $grouped[$hook]['trigger_sources'], true)) {
    817                 $grouped[$hook]['trigger_sources'][] = $log->trigger_source;
    818             }
    819 
    820             // Last run (logs are ordered by started_at DESC, so first occurrence is latest)
    821             if ($grouped[$hook]['last_run'] === null) {
    822                 $grouped[$hook]['last_run'] = $log->started_at;
    823                 $grouped[$hook]['last_run_local'] = get_date_from_gmt($log->started_at, $display_format);
    824                 $grouped[$hook]['last_status'] = $log->status;
    825             }
    826         }
    827 
    828         // Calculate averages and success rate
    829         foreach ($grouped as $hook => &$data) {
    830             $data['avg_duration_ms'] = $data['total_runs'] > 0
    831                 ? round($data['total_duration'] / $data['total_runs'], 1)
    832                 : 0;
    833             $data['success_rate'] = $data['total_runs'] > 0
    834                 ? round(($data['success_count'] / $data['total_runs']) * 100, 0)
    835                 : 0;
    836             unset($data['total_duration']); // Don't send raw total
    837         }
    838         unset($data);
    839 
    840         // Convert to indexed array and sort by last_run
    841         $hooks = array_values($grouped);
    842         usort($hooks, function($a, $b) {
    843             return strcmp($b['last_run'], $a['last_run']);
    844         });
    845772
    846773        wp_send_json_success(array(
     
    873800        $db = DiveWP_DB_Access::get_instance();
    874801        $logs = $db->get_recent_cron_logs($limit, 0, '', $hook);
    875 
    876         // Format the executions
     802        $summary = $db->get_cron_log_summary_for_hook($hook);
     803
     804        // Format the executions (timeline preview, limited)
    877805        $executions = array();
    878806        $display_format = get_option('date_format') . ' ' . get_option('time_format');
     
    890818        }
    891819
    892         // Calculate summary stats
    893         $total_runs = count($executions);
    894         $success_count = 0;
    895         $error_count = 0;
    896         $total_duration = 0;
    897         $min_duration = null;
    898         $max_duration = null;
    899 
    900         foreach ($executions as $exec) {
    901             if ($exec['status'] === 'success') {
    902                 $success_count++;
    903             } elseif ($exec['status'] === 'error') {
    904                 $error_count++;
    905             }
    906             $duration = (int) $exec['duration_ms'];
    907             $total_duration += $duration;
    908             if ($min_duration === null || $duration < $min_duration) {
    909                 $min_duration = $duration;
    910             }
    911             if ($max_duration === null || $duration > $max_duration) {
    912                 $max_duration = $duration;
    913             }
    914         }
    915 
    916820        wp_send_json_success(array(
    917821            'hook' => $hook,
    918822            'executions' => $executions,
    919             'summary' => array(
    920                 'total_runs' => $total_runs,
    921                 'success_count' => $success_count,
    922                 'error_count' => $error_count,
    923                 'success_rate' => $total_runs > 0 ? round(($success_count / $total_runs) * 100, 0) : 0,
    924                 'avg_duration_ms' => $total_runs > 0 ? round($total_duration / $total_runs, 1) : 0,
    925                 'min_duration_ms' => $min_duration,
    926                 'max_duration_ms' => $max_duration,
    927             ),
     823            'summary' => $summary,
    928824        ));
    929825    }
     
    984880        if ($days > 0) {
    985881            $deleted = $db->cleanup_cron_logs($days);
     882            $deleted = $deleted === false ? 0 : (int) $deleted;
     883            if ($deleted > 0) {
     884                $message = sprintf(
     885                    /* translators: 1: Number of log entries deleted, 2: Number of days */
     886                    _n(
     887                        '%1$d log entry deleted (older than %2$d days).',
     888                        '%1$d log entries deleted (older than %2$d days).',
     889                        $deleted,
     890                        'divewp-boost-site-performance'
     891                    ),
     892                    $deleted,
     893                    $days
     894                );
     895            } else {
     896                $message = sprintf(
     897                    /* translators: %d: Number of days */
     898                    __('No logs older than %d days found.', 'divewp-boost-site-performance'),
     899                    $days
     900                );
     901            }
     902        } else {
     903            $total_before = $db->get_total_cron_logs();
     904            $db->delete_all_cron_logs();
    986905            $message = sprintf(
    987                 /* translators: %d: Number of days */
    988                 __('Logs older than %d days have been deleted.', 'divewp-boost-site-performance'),
    989                 $days
     906                /* translators: %d: Number of log entries deleted */
     907                _n(
     908                    '%d log entry deleted.',
     909                    '%d log entries deleted.',
     910                    $total_before,
     911                    'divewp-boost-site-performance'
     912                ),
     913                $total_before
    990914            );
    991         } else {
    992             $db->delete_all_cron_logs();
    993             $message = __('All logs have been deleted.', 'divewp-boost-site-performance');
    994915        }
    995916
  • divewp-boost-site-performance/trunk/includes/features/cron-jobs/class-cron-jobs.php

    r3448398 r3469978  
    187187                /* translators: %d: Number of overdue tasks (plural) */
    188188                'tasks_overdue' => __('%d tasks overdue', 'divewp-boost-site-performance'),
     189                /* translators: %d: Retention period in days */
     190                'retentionNotice' => __('Logs are auto-cleaned daily. Retention: %d days.', 'divewp-boost-site-performance'),
     191                'clearLogsOlderThan' => __('Clear logs older than...', 'divewp-boost-site-performance'),
     192                'clearOlder' => __('Clear older', 'divewp-boost-site-performance'),
     193                'clearAllLogs' => __('Clear All Logs', 'divewp-boost-site-performance'),
     194                /* translators: %d: Number of days */
     195                'confirmClearOlderThan' => __('Delete execution logs older than %d days?', 'divewp-boost-site-performance'),
     196                'confirmClearAllLogs' => __('Delete ALL execution logs? This cannot be undone.', 'divewp-boost-site-performance'),
     197                'clearResultTitle' => __('Clear Logs Result', 'divewp-boost-site-performance'),
     198                'close' => __('Close', 'divewp-boost-site-performance'),
    189199            ),
     200            'retention_days' => absint(apply_filters('divewp_cron_log_retention_days', 30)),
    190201        ));
    191202    }
  • divewp-boost-site-performance/trunk/includes/features/user-events/class-event-logger.php

    r3448398 r3469978  
    165165        // REST API authenticated requests (used for method + route context).
    166166        add_filter('rest_request_before_callbacks', array($this, 'log_rest_api_access'), 10, 3);
     167
     168        // New events
     169        add_action('set_user_role', array($this, 'log_user_role_change'), 10, 3);
     170        add_action('wp_login_failed', array($this, 'log_failed_login'), 10, 2);
     171        add_action('before_delete_post', array($this, 'log_post_permanent_deletion'), 10, 2);
     172        add_action('upgrader_process_complete', array($this, 'log_core_update'), 10, 2);
     173        add_action('wp_ajax_edit-theme-plugin-file', array($this, 'log_file_edit'), 5);
    167174    }
    168175
     
    178185     *     @type string $description   Description of event (max 255 chars)
    179186     *     @type string $status        Status of event (success/warning/error/info)
     187     *     @type array  $actor_snapshot Optional. Structured who (user_id, user_login, user_email)
     188     *     @type string $channel       Optional. Via what (admin_ui, ajax, rest_app_password)
     189     *     @type array  $source_context Optional. Where (screen, route, etc.)
     190     *     @type array  $target_context Optional. Target of action (post_id, plugin_slug, etc.)
     191     *     @type array  $request_metadata Optional. Extra metadata
    180192     * }
    181193     * @return bool|int False on failure, event ID on success
     
    186198                throw new Exception(__('Invalid event data', 'divewp-boost-site-performance'));
    187199            }
    188            
     200
     201            $data['description'] = substr(sanitize_text_field($data['description']), 0, 2000);
     202
     203            $context = $this->build_structured_context($data);
     204            $data = array_merge($context, $data);
     205
    189206            $result = $this->db->log_event($data);
    190207            if (!$result) {
    191208                throw new Exception(__('Failed to insert event', 'divewp-boost-site-performance'));
    192209            }
    193            
     210
    194211            return $result;
    195212        } catch (Exception $e) {
     
    202219            return false;
    203220        }
     221    }
     222
     223    /**
     224     * Build structured context (who, where, via what) for event logging.
     225     * Preserves existing description; adds normalized fields when available.
     226     *
     227     * @since 2.3.3
     228     * @param array $data Event data (user_id, etc.)
     229     * @return array actor_snapshot, channel, source_context, target_context, request_metadata
     230     */
     231    private function build_structured_context($data) {
     232        $user_id = isset($data['user_id']) ? absint($data['user_id']) : get_current_user_id();
     233        $user = $user_id ? get_userdata($user_id) : null;
     234
     235        $actor_snapshot = array(
     236            'user_id' => $user_id,
     237            'user_login' => $user ? sanitize_text_field($user->user_login) : '',
     238            'user_email' => $user ? sanitize_email($user->user_email) : '',
     239        );
     240
     241        $channel = 'admin_ui';
     242        if (defined('REST_REQUEST') && REST_REQUEST && $this->rest_app_password_authenticated) {
     243            $channel = 'rest_app_password';
     244        } elseif (defined('DOING_AJAX') && DOING_AJAX) {
     245            $channel = 'ajax';
     246        }
     247
     248        $source_context = array();
     249        if (function_exists('get_current_screen') && is_admin()) {
     250            $screen = get_current_screen();
     251            if ($screen) {
     252                $source_context['screen'] = sanitize_text_field($screen->id);
     253                $source_context['screen_base'] = sanitize_text_field($screen->base);
     254            }
     255        }
     256        if (empty($source_context)) {
     257            $source_context['context'] = is_admin() ? 'admin' : 'front';
     258        }
     259
     260        return array(
     261            'actor_snapshot' => $actor_snapshot,
     262            'channel' => $channel,
     263            'source_context' => $source_context,
     264        );
    204265    }
    205266
     
    298359                wp_get_current_user()->user_login
    299360            ),
    300             'status' => $this->get_status_for_action($action)
     361            'status' => $this->get_status_for_action($action),
     362            'target_context' => array(
     363                'post_id' => $post->ID,
     364                'post_type' => $post->post_type,
     365                'post_title' => sanitize_text_field($post->post_title),
     366            ),
    301367        ]);
    302368    }
     
    742808                wp_get_current_user()->user_login
    743809            ),
    744             'status' => 'success'
     810            'status' => 'success',
     811            'target_context' => array(
     812                'plugin' => sanitize_text_field($plugin),
     813                'plugin_name' => sanitize_text_field($plugin_name),
     814                'version' => sanitize_text_field($plugin_version),
     815            ),
    745816        ]);
    746817    }
     
    885956                wp_get_current_user()->user_login
    886957            ),
    887             'status' => 'success'
     958            'status' => 'success',
     959            'target_context' => array(
     960                'from_theme' => sanitize_text_field($old_theme->get('Name')),
     961                'to_theme' => sanitize_text_field($new_theme->get('Name')),
     962                'to_version' => sanitize_text_field($new_theme->get('Version')),
     963            ),
    888964        ]);
    889965    }
     
    11801256                $user->user_login
    11811257            ),
    1182             'status' => 'info'
     1258            'status' => 'info',
     1259            'source_context' => array(
     1260                'route' => sanitize_text_field($route),
     1261                'method' => sanitize_text_field($method),
     1262            ),
    11831263        ]);
    11841264
    11851265        $logged = true;
    11861266        return $response;
     1267    }
     1268
     1269    /**
     1270     * Log user role change
     1271     *
     1272     * @param int    $user_id   User ID.
     1273     * @param string $new_role  New role.
     1274     * @param array  $old_roles Old roles.
     1275     */
     1276    public function log_user_role_change($user_id, $new_role, $old_roles) {
     1277        if (!$this->is_admin_user()) {
     1278            return;
     1279        }
     1280
     1281        $user = get_userdata($user_id);
     1282        if (!$user) {
     1283            return;
     1284        }
     1285
     1286        $old_role = is_array($old_roles) && !empty($old_roles) ? reset($old_roles) : '';
     1287
     1288        $this->insert([
     1289            'event_type' => 'user_management',
     1290            'event_action' => 'role_changed',
     1291            'user_id' => get_current_user_id(),
     1292            'description' => sprintf(
     1293                'User "%s" role changed from "%s" to "%s" by Administrator %s',
     1294                $user->user_login,
     1295                $old_role ? translate_user_role($old_role) : '-',
     1296                $new_role ? translate_user_role($new_role) : '-',
     1297                wp_get_current_user()->user_login
     1298            ),
     1299            'status' => 'info',
     1300            'target_context' => array(
     1301                'user_login' => sanitize_text_field($user->user_login),
     1302                'old_role' => sanitize_text_field($old_role),
     1303                'new_role' => sanitize_text_field($new_role),
     1304            ),
     1305        ]);
     1306    }
     1307
     1308    /**
     1309     * Log failed login attempt
     1310     *
     1311     * @param string $username Username attempted.
     1312     * @param object $error    WP_Error object.
     1313     */
     1314    public function log_failed_login($username, $error) {
     1315        $throttle_key = 'divewp_failed_login_' . md5(sanitize_text_field($username));
     1316        if (get_transient($throttle_key)) {
     1317            return;
     1318        }
     1319        set_transient($throttle_key, 1, MINUTE_IN_SECONDS);
     1320
     1321        $this->insert([
     1322            'event_type' => 'admin',
     1323            'event_action' => 'failed_login',
     1324            'user_id' => 0,
     1325            'description' => sprintf(
     1326                'Failed login attempt for username "%s"',
     1327                sanitize_text_field($username)
     1328            ),
     1329            'status' => 'warning',
     1330            'target_context' => array(
     1331                'attempted_username' => sanitize_text_field($username),
     1332            ),
     1333        ]);
     1334    }
     1335
     1336    /**
     1337     * Log post permanent deletion
     1338     *
     1339     * @param int   $post_id Post ID.
     1340     * @param object $post   Post object.
     1341     */
     1342    public function log_post_permanent_deletion($post_id, $post) {
     1343        if (!$this->is_admin_user()) {
     1344            return;
     1345        }
     1346
     1347        if (!is_object($post)) {
     1348            $post = get_post($post_id);
     1349        }
     1350        if (!$post || wp_is_post_revision($post_id) || (isset($post->post_status) && $post->post_status === 'auto-draft')) {
     1351            return;
     1352        }
     1353
     1354        $post_type = get_post_type_object($post->post_type);
     1355        $post_type_label = $post_type ? $post_type->labels->singular_name : $post->post_type;
     1356
     1357        $this->insert([
     1358            'event_type' => 'content',
     1359            'event_action' => 'deleted_permanently',
     1360            'user_id' => get_current_user_id(),
     1361            'description' => sprintf(
     1362                '%s "%s" was permanently deleted by Administrator %s',
     1363                $post_type_label,
     1364                $post->post_title,
     1365                wp_get_current_user()->user_login
     1366            ),
     1367            'status' => 'danger',
     1368            'target_context' => array(
     1369                'post_id' => $post_id,
     1370                'post_type' => sanitize_text_field($post->post_type),
     1371                'post_title' => sanitize_text_field($post->post_title),
     1372            ),
     1373        ]);
     1374    }
     1375
     1376    /**
     1377     * Log WordPress core update
     1378     *
     1379     * @param object $upgrader Upgrader instance.
     1380     * @param array  $options  Upgrade options.
     1381     */
     1382    public function log_core_update($upgrader_object, $options) {
     1383        static $logged = false;
     1384        if ($logged) {
     1385            return;
     1386        }
     1387
     1388        if (!$this->is_admin_user() ||
     1389            !isset($options['type']) || $options['type'] !== 'core' ||
     1390            !isset($options['action']) || $options['action'] !== 'update') {
     1391            return;
     1392        }
     1393
     1394        $version = get_bloginfo('version');
     1395
     1396        $this->insert([
     1397            'event_type' => 'admin',
     1398            'event_action' => 'core_updated',
     1399            'user_id' => get_current_user_id(),
     1400            'description' => sprintf(
     1401                'WordPress core updated to v%s by Administrator %s',
     1402                $version,
     1403                wp_get_current_user()->user_login
     1404            ),
     1405            'status' => 'success',
     1406            'target_context' => array(
     1407                'version' => sanitize_text_field($version),
     1408            ),
     1409        ]);
     1410
     1411        $logged = true;
     1412    }
     1413
     1414    /**
     1415     * Log file edit via theme/plugin editor
     1416     */
     1417    public function log_file_edit() {
     1418        if (!$this->is_admin_user()) {
     1419            return;
     1420        }
     1421
     1422        if (!isset($_POST['file'])) {
     1423            return;
     1424        }
     1425
     1426        $file = sanitize_text_field(wp_unslash($_POST['file']));
     1427        if (empty($file)) {
     1428            return;
     1429        }
     1430
     1431        $editor_type = isset($_POST['plugin']) ? 'plugin' : 'theme';
     1432        if ('plugin' === $editor_type) {
     1433            $nonce_action = 'edit-plugin_' . $file;
     1434        } else {
     1435            $stylesheet = isset($_POST['theme']) ? sanitize_text_field(wp_unslash($_POST['theme'])) : '';
     1436            if (empty($stylesheet)) {
     1437                return;
     1438            }
     1439            $nonce_action = 'edit-theme_' . $stylesheet . '_' . $file;
     1440        }
     1441        $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
     1442        if (!wp_verify_nonce($nonce, $nonce_action)) {
     1443            return;
     1444        }
     1445
     1446        $this->insert([
     1447            'event_type' => 'admin',
     1448            'event_action' => 'file_edited',
     1449            'user_id' => get_current_user_id(),
     1450            'description' => sprintf(
     1451                '%s file "%s" was edited by Administrator %s',
     1452                ucfirst($editor_type),
     1453                $file,
     1454                wp_get_current_user()->user_login
     1455            ),
     1456            'status' => 'warning',
     1457            'target_context' => array(
     1458                'file' => $file,
     1459                'editor_type' => $editor_type,
     1460            ),
     1461        ]);
    11871462    }
    11881463
     
    12131488     */
    12141489    private function validate_event_data($data) {
    1215         // Sanitize input data
    1216         $data = array_map('sanitize_text_field', $data);
    1217        
    1218         // Validate event type
    1219         if (!in_array($data['event_type'], self::VALID_EVENT_TYPES)) {
     1490        if (!is_array($data) || empty($data['event_type']) || empty($data['description'])) {
    12201491            return false;
    12211492        }
    1222        
    1223         // Truncate description if too long
    1224         $data['description'] = substr($data['description'], 0, 255);
    1225        
     1493
     1494        $event_type = sanitize_text_field($data['event_type']);
     1495        if (!in_array($event_type, self::VALID_EVENT_TYPES)) {
     1496            return false;
     1497        }
     1498
    12261499        return true;
    12271500    }
  • divewp-boost-site-performance/trunk/includes/features/user-events/class-user-events.php

    r3448398 r3469978  
    6060        add_action('wp_ajax_divewp_load_more_events', array($this, 'ajax_load_more_events'));
    6161        add_action('wp_ajax_divewp_load_recent_timeline', array($this, 'ajax_load_recent_timeline'));
     62        add_action('wp_ajax_divewp_get_event_details', array($this, 'ajax_get_event_details'));
    6263
    6364        // Admin scripts and styles
     
    7374        }
    7475
    75         // Enqueue User Events CSS
     76        // Enqueue User Events CSS (depends on cron-jobs for shared modal-details styles)
    7677        wp_enqueue_style(
    7778            'divewp-user-events',
    7879            DIVEWP_PLUGIN_URL . 'assets/css/features/user-events.css',
    79             array(),
     80            array('divewp-cron-jobs'),
    8081            DIVEWP_VERSION
    8182        );
     
    154155            echo '<div class="divewp-actions">';
    155156            /* translators: Button text to refresh the activity logs */
    156             echo '<button id="divewp-refresh-logs" class="button button-primary" style="margin-right: 10px;">' .
    157                  esc_html__('Refresh Logs', 'divewp-boost-site-performance') . 
     157            echo '<button id="divewp-refresh-logs" class="button button-primary">' .
     158                 esc_html__('Refresh Logs', 'divewp-boost-site-performance') .
    158159                 '</button>';
    159160            /* translators: Button text to delete all activity logs */
     
    194195
    195196        foreach ($user_events_data as $event) {
    196             echo '<tr>';
    197             echo '<td>' . esc_html($event->user_login) . '</td>';
     197            $event_id = isset($event->id) ? absint($event->id) : 0;
     198            echo '<tr class="divewp-event-row' . ( $event_id ? ' divewp-event-row--clickable' : '' ) . '"' . ( $event_id ? ' data-event-id="' . esc_attr($event_id) . '"' : '' ) . '>';
     199            echo '<td>' . esc_html($event->user_login ?? '') . '</td>';
    198200            echo '<td>' . esc_html($this->get_event_type_label($event->event_type)) . '</td>';
    199201            echo '<td>';
     
    227229        }
    228230
     231        $this->render_event_details_drawer();
    229232        echo '</div>'; // #divewp-logs-container
     233    }
     234
     235    /**
     236     * Render event details drawer (modal) markup.
     237     *
     238     * @since 2.3.3
     239     */
     240    private function render_event_details_drawer() {
     241        ?>
     242        <div class="divewp-event-drawer" aria-hidden="true">
     243            <div class="divewp-event-drawer__overlay"></div>
     244            <div class="divewp-event-drawer__panel">
     245                <div class="divewp-event-drawer__header">
     246                    <h4 class="divewp-event-drawer__title"><?php esc_html_e('Event Details', 'divewp-boost-site-performance'); ?></h4>
     247                    <button type="button" class="divewp-event-drawer__close" aria-label="<?php esc_attr_e('Close', 'divewp-boost-site-performance'); ?>">
     248                        <span class="dashicons dashicons-no-alt"></span>
     249                    </button>
     250                </div>
     251                <div class="divewp-event-drawer__content">
     252                    <!-- Content loaded dynamically -->
     253                </div>
     254            </div>
     255        </div>
     256        <?php
    230257    }
    231258
     
    363390            'edited' => __('Edited', 'divewp-boost-site-performance'),
    364391            /* translators: Action label when content is published */
    365             'published' => __('Published', 'divewp-boost-site-performance')
     392            'published' => __('Published', 'divewp-boost-site-performance'),
     393
     394            // New events
     395            /* translators: Action label when user role is changed */
     396            'role_changed' => __('Role Changed', 'divewp-boost-site-performance'),
     397            /* translators: Action label for failed login attempt */
     398            'failed_login' => __('Failed Login', 'divewp-boost-site-performance'),
     399            /* translators: Action label when content is permanently deleted */
     400            'deleted_permanently' => __('Deleted', 'divewp-boost-site-performance'),
     401            /* translators: Action label when WordPress core is updated */
     402            'core_updated' => __('Core Updated', 'divewp-boost-site-performance'),
     403            /* translators: Action label when theme/plugin file is edited */
     404            'file_edited' => __('File Edited', 'divewp-boost-site-performance')
    366405        );
    367406
     
    470509
    471510        foreach ($events as $event) {
    472             echo '<tr>';
    473             echo '<td>' . esc_html($event->user_login) . '</td>';
     511            $event_id = isset($event->id) ? absint($event->id) : 0;
     512            echo '<tr class="divewp-event-row' . ( $event_id ? ' divewp-event-row--clickable' : '' ) . '"' . ( $event_id ? ' data-event-id="' . esc_attr($event_id) . '"' : '' ) . '>';
     513            echo '<td>' . esc_html($event->user_login ?? '') . '</td>';
    474514            echo '<td>' . esc_html($this->get_event_type_label($event->event_type)) . '</td>';
    475515            echo '<td>';
     
    509549            echo '<div class="divewp-timeline">';
    510550            $current_date = '';
     551            $today_local = wp_date('Y-m-d');
    511552            foreach ($recent_events as $event) {
    512                 // Get the site's timezone
    513553                $timezone = wp_timezone();
    514                
    515                 // Convert UTC timestamp to site's timezone
    516554                $datetime = new DateTime($event->created_at, new DateTimeZone('UTC'));
    517555                $datetime->setTimezone($timezone);
    518                
    519556                $event_date = $datetime->format('Y-m-d');
    520557                $event_time = $datetime->format('H:i');
    521                
     558
    522559                if ($event_date !== $current_date) {
    523                     if ($current_date === '') {
    524                         echo '<div class="divewp-timeline-date today">';
    525                         echo '<span class="date-label">' . esc_html__('Today', 'divewp-boost-site-performance') . '</span>';
    526                     } else {
    527                         echo '<div class="divewp-timeline-date">';
    528                         echo '<span class="date-label">' . esc_html($datetime->format('F j')) . '</span>';
    529                     }
     560                    echo '<div class="divewp-timeline-date' . ( $event_date === $today_local ? ' today' : '' ) . '">';
     561                    echo '<span class="date-label">';
     562                    echo $event_date === $today_local
     563                        ? esc_html__('Today', 'divewp-boost-site-performance')
     564                        : esc_html($datetime->format('F j'));
     565                    echo '</span>';
    530566                    echo '</div>';
    531567                    $current_date = $event_date;
     
    566602
    567603    /**
     604     * AJAX handler for event details (modal)
     605     *
     606     * @since 2.3.3
     607     */
     608    public function ajax_get_event_details() {
     609        if (!isset($_REQUEST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['nonce'])), 'divewp_nonce')) {
     610            wp_send_json_error(array('message' => esc_html__('Security verification failed', 'divewp-boost-site-performance')));
     611            return;
     612        }
     613        if (!current_user_can('manage_options')) {
     614            wp_send_json_error(array('message' => esc_html__('Permission denied', 'divewp-boost-site-performance')));
     615            return;
     616        }
     617
     618        $event_id = isset($_POST['event_id']) ? absint($_POST['event_id']) : 0;
     619        if (!$event_id) {
     620            wp_send_json_error(array('message' => esc_html__('Invalid event', 'divewp-boost-site-performance')));
     621            return;
     622        }
     623
     624        $db = DiveWP_DB_Access::get_instance();
     625        $event = $db->get_event_by_id($event_id);
     626        if (!$event) {
     627            wp_send_json_error(array('message' => esc_html__('Event not found', 'divewp-boost-site-performance')));
     628            return;
     629        }
     630
     631        $display_format = get_option('date_format') . ' ' . get_option('time_format');
     632        $timezone = wp_timezone();
     633        $datetime = new DateTime($event->created_at, new DateTimeZone('UTC'));
     634        $datetime->setTimezone($timezone);
     635        $created_at_local = $datetime->format($display_format);
     636
     637        $actor = array(
     638            'user_login' => $event->user_login ?? '',
     639            'user_id' => $event->user_id ?? 0,
     640        );
     641        if (!empty($event->actor_snapshot)) {
     642            $decoded = json_decode($event->actor_snapshot, true);
     643            if (is_array($decoded)) {
     644                $actor = array_merge($actor, $decoded);
     645            }
     646        }
     647
     648        $channel_labels = array(
     649            'admin_ui' => __('Admin dashboard', 'divewp-boost-site-performance'),
     650            'ajax' => __('AJAX request', 'divewp-boost-site-performance'),
     651            'rest_app_password' => __('REST API (Application Password)', 'divewp-boost-site-performance'),
     652        );
     653        $channel = isset($channel_labels[ $event->channel ?? '' ]) ? $channel_labels[ $event->channel ] : ($event->channel ?? __('Unknown', 'divewp-boost-site-performance'));
     654
     655        $source_context = array();
     656        if (!empty($event->source_context)) {
     657            $decoded = json_decode($event->source_context, true);
     658            if (is_array($decoded)) {
     659                $source_context = $decoded;
     660            }
     661        }
     662
     663        $target_context = array();
     664        if (!empty($event->target_context)) {
     665            $decoded = json_decode($event->target_context, true);
     666            if (is_array($decoded)) {
     667                $target_context = $decoded;
     668            }
     669        }
     670
     671        $plain_summary = sprintf(
     672            /* translators: 1: who, 2: what, 3: when */
     673            __('%1$s performed this action on %2$s.', 'divewp-boost-site-performance'),
     674            esc_html($actor['user_login'] ?: __('Unknown user', 'divewp-boost-site-performance')),
     675            esc_html($created_at_local)
     676        );
     677
     678        wp_send_json_success(array(
     679            'event' => $event,
     680            'plain_summary' => $plain_summary,
     681            'created_at_local' => $created_at_local,
     682            'actor' => $actor,
     683            'channel' => $channel,
     684            'source_context' => $source_context,
     685            'target_context' => $target_context,
     686            'event_type_label' => $this->get_event_type_label($event->event_type),
     687            'action_label' => $this->get_action_label($event->event_action, $event->event_type),
     688        ));
     689    }
     690
     691    /**
    568692     * Get action class for event styling
    569693     *
     
    573697     */
    574698    private function get_action_class($action) {
    575         // Document the action-class mapping
     699        // Document the action-class mapping (danger = red, warning = orange, success = green, info = blue)
    576700        $classes = array(
    577             'created' => 'success',     // Creation actions
    578             'creation' => 'success',    // User creation
    579             'login' => 'success',       // Login events
    580             'activated' => 'success',   // Plugin/theme activation
    581             'installed' => 'success',   // Installation events
    582             'restored' => 'success',    // Restoration events
    583             'updated' => 'info',        // Update actions
    584             'update' => 'info',         // General updates
    585             'logout' => 'info',         // Logout events
    586             'trashed' => 'warning',     // Trash actions
    587             'unpublished' => 'warning', // Unpublish events
    588             'deactivated' => 'warning', // Deactivation events
    589             'deletion' => 'danger',     // Deletion events
    590             'deleted' => 'danger',      // Delete actions
    591             'authenticated' => 'info'   // REST API authenticated access
     701            'created' => 'success',        // Creation actions
     702            'creation' => 'success',       // User creation
     703            'login' => 'success',          // Login events
     704            'activated' => 'success',      // Plugin/theme activation
     705            'installed' => 'success',      // Installation events
     706            'restored' => 'success',       // Restoration events
     707            'approved' => 'success',       // Comment approved
     708            'updated' => 'info',           // Update actions
     709            'update' => 'info',            // General updates
     710            'logout' => 'info',            // Logout events
     711            'edited' => 'info',            // Edit actions
     712            'customized' => 'info',        // Theme customization
     713            'authenticated' => 'info',     // REST API authenticated access
     714            'core_updated' => 'info',      // Core update
     715            'role_changed' => 'info',      // Role change
     716            'trashed' => 'danger',         // Trash (destructive)
     717            'unpublished' => 'warning',    // Unpublish events
     718            'deactivated' => 'warning',    // Deactivation events
     719            'deletion' => 'danger',        // User deletion
     720            'deleted' => 'danger',         // Delete actions
     721            'deleted_permanently' => 'danger', // Permanent post deletion
     722            'failed_login' => 'warning',   // Failed login attempt
     723            'file_edited' => 'warning',    // File edit (security-sensitive)
     724            'password_reset' => 'warning', // Password reset
    592725        );
    593726
Note: See TracChangeset for help on using the changeset viewer.