Plugin Directory

Changeset 3763178


Ignore:
Timestamp:
10/24/2026 11:15:37 AM (69 minutes ago)
Author:
Qndrs
Message:

Release 3.7.1: display themes, configurable columns and reliability fixes

Location:
qndrs-availability-heartbeat-monitor/trunk
Files:
11 edited

Legend:

Unmodified
Added
Removed
  • qndrs-availability-heartbeat-monitor/trunk/README.md

    r3666118 r3763178  
    1414- Tested up to: WordPress 7.1
    1515- Requires PHP: 8.0+
    16 - Stable tag: 3.6.1
     16- Stable tag: 3.7.1
    1717- License: GPLv2 or later
    1818- License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    3232- Monitor firewalled machines or internal apps with outbound heartbeat pings.
    3333- Dutch translation files are included.
     34- Choose a light, dark or system theme for the monitor overview.
     35- Show or hide overview columns, with a compact preset and a reset to the full view.
     36
     37## Overview Display
     38
     39Use **Light / Dark / System** and **Columns** above the overview to choose your display. Last check, response time, uptime, notifications and incidents can be switched independently. Monitor type and URL/heartbeat interval can also be hidden. Status, monitor name and management actions remain available.
     40
     41The initial view is light with all columns visible. Display preferences are saved in this browser for the current site and WordPress user, and survive automatic refresh. If browser storage is unavailable, the controls still work for the current page. System mode follows the operating system's color preference. The dark theme applies to the monitor overview; the settings page, WordPress menu and dashboard widget keep their existing appearance.
    3442
    3543## Installation
    … …  
    37451. Upload the plugin folder to `/wp-content/plugins/qndrs-availability-heartbeat-monitor`.
    38462. Activate **Qndrs Availability and Heartbeat Monitor** from the WordPress plugins page.
    39 3. Open **Qndrs Monitor** in the WordPress admin menu.
     473. Open **Qndrs Monitor > Overview** in the WordPress admin menu. Use **Qndrs Monitor > Settings** for configuration.
    40484. Add one or more URLs and choose the notification channels.
    41495. Optional: use a real server cron for more reliable checks:
    … …  
    142150The checks validate PHP syntax, JavaScript syntax when Node.js is available, version consistency, and release packaging basics.
    143151
     152Run the standalone reliability regression checks with `php scripts/check-reliability.php`. These use WordPress and database doubles to cover migration recovery, aggregate status and concurrent heartbeat writes; they do not replace WordPress/MySQL integration testing.
     153
     154Run `php scripts/check-overview.php` to also check the production overview renderer. For browser checks with the actual PHP output, JavaScript, CSS and an AJAX fixture, see `docs/dashboard-design.md`.
     155
     156Overall status is `unknown` while any active monitor has no result, or when there are no active monitors. Incidents and degraded results take precedence. Concurrent heartbeat updates merge with the latest stored monitor list; database failures or exhausted retries return HTTP 503 for the sender to retry.
     157
    144158### Dutch translation maintenance
    145159
    … …  
    153167
    154168## Changelog
     169
     170### 3.7.1
     171
     172- Renamed the first submenu to Overview to distinguish it from the Qndrs Monitor parent menu; Settings remains the configuration page.
     173
     174### 3.7.0
     175
     176- Added light, dark and system themes with configurable overview columns and a compact view.
     177- Preserved display preferences and expanded history during automatic refresh.
     178- Fixed shadow-history migration recovery, unknown aggregate status and concurrent heartbeat persistence.
     179- Added Dutch translations for the new display controls.
    155180
    156181### 3.6.1
  • qndrs-availability-heartbeat-monitor/trunk/css/uptime-monitor.css

    r3608284 r3763178  
    1818}
    1919
     20/* Overview preferences are deliberately scoped away from settings and the widget. */
     21.uptime-monitor-overview {
     22    --ump-columns: 90px minmax(190px, 1.4fr) minmax(150px, .85fr) minmax(90px, .5fr) minmax(190px, 1fr) minmax(100px, .6fr) minmax(100px, .6fr) 340px;
     23    --ump-list-min-width: 1386px;
     24    --ump-soft: #f6f7f7;
     25    --ump-link: #2271b1;
     26    --ump-shadow: 0 12px 34px rgba(0, 0, 0, .16);
     27}
     28
     29.uptime-monitor-overview [hidden] { display: none !important; }
     30.uptime-view-controls { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; margin: 12px 0; }
     31.uptime-theme-switch { background: var(--ump-panel); border: 1px solid var(--ump-border); border-radius: 6px; display: flex; padding: 3px; }
     32.uptime-theme-switch button { background: transparent; border: 0; border-radius: 3px; color: var(--ump-muted); cursor: pointer; font: inherit; padding: 6px 12px; }
     33.uptime-theme-switch button[aria-pressed="true"] { background: var(--ump-soft); color: var(--ump-text); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
     34.uptime-monitor-overview :is(button, summary, input, a):focus-visible { outline: 2px solid var(--ump-link); outline-offset: 2px; }
     35.uptime-column-settings { position: relative; }
     36.uptime-column-settings > summary { cursor: pointer; list-style: none; }
     37.uptime-column-settings > summary::-webkit-details-marker { display: none; }
     38.uptime-column-settings[open] > summary { border-color: var(--ump-link); }
     39.uptime-column-count { border-left: 1px solid var(--ump-border); color: var(--ump-muted); font-size: 11px; margin-left: 6px; padding-left: 8px; }
     40.uptime-column-panel { background: var(--ump-panel); border: 1px solid var(--ump-border); border-radius: 6px; box-shadow: var(--ump-shadow); box-sizing: border-box; max-height: 65vh; overflow-y: auto; padding: 16px; position: absolute; right: 0; top: calc(100% + 8px); width: 310px; z-index: 20; }
     41.uptime-column-panel p { color: var(--ump-muted); font-size: 12px; margin: 0 0 10px; }
     42.uptime-column-panel fieldset { border: 0; margin: 0; min-width: 0; padding: 0; }
     43.uptime-column-panel label { align-items: center; cursor: pointer; display: flex; gap: 9px; padding: 7px 0; }
     44.uptime-column-panel input[type="checkbox"] { flex: 0 0 auto; margin: 0; }
     45.uptime-column-panel-actions { border-top: 1px solid var(--ump-border); display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 12px; }
     46.uptime-column-panel-actions .button { flex: 1 1 auto; }
     47.uptime-url-list-scroll { overflow-x: auto; padding: 2px 2px 8px; scrollbar-color: var(--ump-border) transparent; }
     48.uptime-url-column-head { align-items: center; box-sizing: border-box; color: var(--ump-muted); display: grid; font-size: 11px; font-weight: 600; gap: 12px 14px; grid-template-columns: var(--ump-columns); min-width: var(--ump-list-min-width); padding: 8px 17px 8px 21px; }
     49.uptime-monitor-overview .uptime-url-list { min-width: var(--ump-list-min-width); }
     50.uptime-url-list-scroll.is-empty .uptime-url-column-head { display: none; }
     51.uptime-monitor-overview .uptime-url-list-scroll.is-empty .uptime-url-list { min-width: 0; }
     52.uptime-monitor-overview .uptime-url-row { gap: 12px 14px; grid-template-columns: var(--ump-columns); }
     53.uptime-monitor-overview .uptime-url-actions { justify-content: flex-end; }
     54.uptime-url-response, .uptime-url-incident { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
     55.uptime-hide-check [data-view-column="check"],
     56.uptime-hide-response [data-view-column="response"],
     57.uptime-hide-uptime [data-view-column="uptime"],
     58.uptime-hide-alerts [data-view-column="alerts"],
     59.uptime-hide-incident [data-view-column="incident"],
     60.uptime-hide-type [data-view-column="type"],
     61.uptime-hide-address [data-view-column="address"] { display: none !important; }
     62
     63.uptime-monitor-overview[data-theme="dark"] {
     64    --ump-bg: #10161f;
     65    --ump-panel: #19222e;
     66    --ump-soft: #202c3a;
     67    --ump-border: #344152;
     68    --ump-text: #e7edf5;
     69    --ump-muted: #a9b6c7;
     70    --ump-up: #6ad79a;
     71    --ump-down: #ff9d9d;
     72    --ump-paused: #a9b6c7;
     73    --ump-warning: #f0c36d;
     74    --ump-info: #9bbcff;
     75    --ump-link: #9bbcff;
     76    --ump-shadow: 0 12px 34px rgba(0, 0, 0, .4);
     77    background: var(--ump-bg);
     78    color-scheme: dark;
     79}
     80body.qndrs-ahm-dark-overview #wpcontent,
     81body.qndrs-ahm-dark-overview #wpfooter { background: #10161f; }
     82body.qndrs-ahm-dark-overview #wpfooter { color: #a9b6c7; }
     83body.qndrs-ahm-dark-overview #wpfooter a { color: #9bbcff; }
     84.uptime-monitor-overview[data-theme="dark"] :is(h1, h2, h3, label) { color: var(--ump-text); }
     85.uptime-monitor-overview[data-theme="dark"] a { color: var(--ump-link); }
     86.uptime-monitor-overview[data-theme="dark"] .button { background: var(--ump-panel); border-color: var(--ump-border); color: var(--ump-link); }
     87.uptime-monitor-overview[data-theme="dark"] .button:hover { background: var(--ump-soft); border-color: var(--ump-link); }
     88.uptime-monitor-overview[data-theme="dark"] .button-primary { background: #5269ee; border-color: #5269ee; color: #fff; }
     89.uptime-monitor-overview[data-theme="dark"] .button-primary:hover { background: #4359d1; border-color: #4359d1; }
     90.uptime-monitor-overview[data-theme="dark"] .button:disabled { background: var(--ump-soft) !important; border-color: var(--ump-border) !important; color: var(--ump-muted) !important; }
     91.uptime-monitor-overview[data-theme="dark"] :is(input[type="url"], input[type="text"], input[type="checkbox"]) { background: var(--ump-bg); border-color: var(--ump-muted); color: var(--ump-text); }
     92.uptime-monitor-overview[data-theme="dark"] input::placeholder { color: var(--ump-muted); }
     93.uptime-monitor-overview[data-theme="dark"] .uptime-chip { background: var(--ump-soft); border-color: var(--ump-border); color: var(--ump-text); }
     94.uptime-monitor-overview[data-theme="dark"] .uptime-chip-muted { color: var(--ump-muted); }
     95.uptime-monitor-overview[data-theme="dark"] .uptime-chip.is-warning { background: #382e1d; border-color: #706044; color: var(--ump-warning); }
     96.uptime-monitor-overview[data-theme="dark"] .uptime-chip.is-danger { background: #382329; border-color: #70404b; color: var(--ump-down); }
     97.uptime-monitor-overview[data-theme="dark"] .uptime-percentage { background: var(--ump-soft); border-color: var(--ump-border); }
     98.uptime-monitor-overview[data-theme="dark"] .uptime-percentage-label { background: #2b3747; color: var(--ump-muted); }
     99.uptime-monitor-overview[data-theme="dark"] .uptime-percentage-value { color: var(--ump-up); }
     100.uptime-monitor-overview[data-theme="dark"] :is(.uptime-response-average, .uptime-history-response-time) { background: #20364a; color: #acdcff; }
     101.uptime-monitor-overview[data-theme="dark"] :is(.uptime-history-empty, .uptime-history-time, .uptime-history-message) { color: var(--ump-muted); }
     102.uptime-monitor-overview[data-theme="dark"] .uptime-history-code { color: var(--ump-text); }
     103.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-up, .uptime-response-trend-faster) { background: #213b30; color: var(--ump-up); }
     104.uptime-monitor-overview[data-theme="dark"] .uptime-status-down { background: #44282f; color: var(--ump-down); }
     105.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-error, .uptime-status-degraded, .uptime-response-trend-slower) { background: #382e1d; color: var(--ump-warning); }
     106.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-paused, .uptime-status-unknown, .uptime-response-trend-stable) { background: var(--ump-soft); color: var(--ump-muted); }
     107.uptime-monitor-overview[data-theme="dark"] .uptime-delete-button { border-color: #70404b; color: var(--ump-down); }
     108.uptime-monitor-overview[data-theme="dark"] .uptime-delete-button:hover { background: #44282f; border-color: var(--ump-down); }
     109.uptime-monitor-overview[data-theme="dark"] .uptime-monitor-notice { background: var(--ump-panel); color: var(--ump-text); }
     110.uptime-monitor-overview[data-theme="dark"] .notice-dismiss::before { color: var(--ump-muted); }
     111
     112@media (max-width: 782px) {
     113    .uptime-monitor-overview .uptime-dashboard-header { align-items: flex-start; flex-direction: column; }
     114    .uptime-view-controls { justify-content: space-between; }
     115    .uptime-column-settings { margin-left: auto; }
     116    .uptime-column-panel { max-width: calc(100vw - 40px); }
     117}
     118
    20119.uptime-widget-refresh-meter {
    21120    background: rgba(34, 113, 177, 0.12);
    … …  
    62161    border-left-color: var(--ump-warning);
    63162    background: rgba(183, 121, 31, 0.07);
     163}
     164
     165.uptime-dashboard-widget.is-unknown .uptime-widget-status {
     166    border-left-color: var(--ump-paused);
    64167}
    65168
    … …  
    219322.uptime-statusbar.is-degraded {
    220323    border-left-color: var(--ump-warning);
     324}
     325
     326.uptime-statusbar.is-unknown {
     327    border-left-color: var(--ump-paused);
    221328}
    222329
  • qndrs-availability-heartbeat-monitor/trunk/docs/history-storage.md

    r3651690 r3763178  
    3636```
    3737
    38 The migration uses idempotent batched inserts and switches to `table_shadow` only when the valid legacy count exactly matches the table count. The legacy option is retained at this stage.
     38Pause scheduled checks and heartbeat senders and let in-flight writes finish before running migration, rollback or finalization. These maintenance commands compare snapshots and must not overlap history writes.
     39
     40Migration rebuilds the unfinalized table copy from legacy history using batched inserts and switches to `table_shadow` only when the valid legacy count exactly matches the table count. Rerunning migration repairs extra rows left by earlier retention or interrupted migrations. Failed imports leave legacy reads active. Migration refuses to rebuild finalized table storage. The legacy option is retained at this stage.
    3941
    4042Compare the dashboard metrics and recent history before finalizing. Roll back while shadow mode is active with:
    … …  
    5658The dashboard loads only the five newest rows per monitor. Uptime totals and response-time statistics are calculated with indexed aggregate queries rather than by loading all history into PHP memory.
    5759
    58 Rows older than 30 days are removed at most once per hour when a new history event is stored.
     60In table-only mode, rows older than 30 days are removed at most once per hour when a new history event is stored. During shadow verification, neither copy is pruned, including the legacy per-monitor entry cap, so the two counts remain comparable. Finalize after verification to resume retention cleanup; leaving shadow mode active indefinitely allows history to grow.
  • qndrs-availability-heartbeat-monitor/trunk/js/uptime-monitor.js

    r3651690 r3763178  
    66    let widgetRefreshTimer = null;
    77    let widgetRefreshRequest = null;
     8    const overview = $('.uptime-monitor-overview');
     9    const viewPreferences = { theme: 'light', hidden: [] };
     10    const viewColumns = {
     11        check: { track: 'minmax(150px, .85fr)', width: 164 },
     12        response: { track: 'minmax(90px, .5fr)', width: 104 },
     13        uptime: { track: 'minmax(190px, 1fr)', width: 204 },
     14        alerts: { track: 'minmax(100px, .6fr)', width: 114 },
     15        incident: { track: 'minmax(100px, .6fr)', width: 114 }
     16    };
     17    const optionalViewFields = [...Object.keys(viewColumns), 'type', 'address'];
     18    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)');
     19
     20    function applyViewTheme() {
     21        if (!overview.length) {
     22            return;
     23        }
     24        const theme = viewPreferences.theme === 'system' ? (systemTheme.matches ? 'dark' : 'light') : viewPreferences.theme;
     25        overview.attr('data-theme', theme);
     26        $('body').toggleClass('qndrs-ahm-dark-overview', theme === 'dark');
     27        overview.find('[data-theme-choice]').each(function () {
     28            $(this).attr('aria-pressed', String(this.dataset.themeChoice === viewPreferences.theme));
     29        });
     30    }
     31
     32    function applyViewColumns() {
     33        if (!overview.length) {
     34            return;
     35        }
     36        const visible = Object.keys(viewColumns).filter(key => !viewPreferences.hidden.includes(key));
     37        overview[0].style.setProperty('--ump-columns', [
     38            '90px', 'minmax(190px, 1.4fr)', ...visible.map(key => viewColumns[key].track), '340px'
     39        ].join(' '));
     40        overview[0].style.setProperty('--ump-list-min-width', (686 + visible.reduce((width, key) => width + viewColumns[key].width, 0)) + 'px');
     41        optionalViewFields.forEach(function (key) {
     42            const hidden = viewPreferences.hidden.includes(key);
     43            overview.toggleClass('uptime-hide-' + key, hidden);
     44            overview.find('[data-view-toggle="' + key + '"]').prop('checked', !hidden);
     45        });
     46        overview.find('.uptime-column-count').text((optionalViewFields.length - viewPreferences.hidden.length) + '/' + optionalViewFields.length);
     47    }
     48
     49    function saveViewPreferences() {
     50        try {
     51            window.localStorage.setItem(qndrsAhmAjax.view_storage_key, JSON.stringify(viewPreferences));
     52        } catch (error) {
     53            // Preferences still work for this page when browser storage is unavailable.
     54        }
     55    }
     56
     57    function initializeViewPreferences() {
     58        if (!overview.length) {
     59            return;
     60        }
     61        try {
     62            const saved = JSON.parse(window.localStorage.getItem(qndrsAhmAjax.view_storage_key));
     63            if (saved && ['light', 'dark', 'system'].includes(saved.theme)) {
     64                viewPreferences.theme = saved.theme;
     65            }
     66            if (saved && Array.isArray(saved.hidden)) {
     67                viewPreferences.hidden = optionalViewFields.filter(key => saved.hidden.includes(key));
     68            }
     69        } catch (error) {
     70            // Corrupt or blocked storage must not prevent monitoring controls from working.
     71        }
     72        applyViewTheme();
     73        applyViewColumns();
     74        overview.find('.uptime-view-controls').prop('hidden', false);
     75        overview.on('click', '[data-theme-choice]', function () {
     76            viewPreferences.theme = this.dataset.themeChoice;
     77            applyViewTheme();
     78            saveViewPreferences();
     79        });
     80        overview.on('change', '[data-view-toggle]', function () {
     81            viewPreferences.hidden = optionalViewFields.filter(key => !overview.find('[data-view-toggle="' + key + '"]').prop('checked'));
     82            applyViewColumns();
     83            saveViewPreferences();
     84        });
     85        overview.on('click', '.uptime-view-compact, .uptime-view-reset', function () {
     86            viewPreferences.hidden = $(this).hasClass('uptime-view-compact') ? ['check', 'alerts', 'incident', 'type', 'address'] : [];
     87            applyViewColumns();
     88            saveViewPreferences();
     89        });
     90        $(document).on('click.qndrsAhmView', function (event) {
     91            const settings = overview.find('.uptime-column-settings');
     92            if (!settings[0].contains(event.target)) {
     93                settings.prop('open', false);
     94            }
     95        }).on('keydown.qndrsAhmView', function (event) {
     96            const settings = overview.find('.uptime-column-settings');
     97            if (event.key === 'Escape' && settings.prop('open')) {
     98                settings.prop('open', false).find('summary').trigger('focus');
     99            }
     100        });
     101        systemTheme.addEventListener('change', applyViewTheme);
     102    }
     103
     104    initializeViewPreferences();
    8105
    9106    function escapeHtml(value) {
    … …  
    205302
    206303        const statusCode = parseInt(latest.status_code, 10);
    207         const responseTime = parseInt(latest.response_time_ms, 10);
    208304        const timestamp = latest.timestamp || '';
    209305        const displayTimestamp = latest.display_timestamp || timestamp;
    … …  
    211307        return `
    212308            ${statusCode ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.http_status.replace('%d', statusCode))}</span>` : ''}
    213             ${!isNaN(responseTime) ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.response_time_ms.replace('%d', responseTime))}</span>` : ''}
    214309            ${timestamp ? `<time class="uptime-chip uptime-chip-muted" datetime="${escapeHtml(timestamp)}">${escapeHtml(displayTimestamp)}</time>` : ''}
    215310        `;
    … …  
    229324        const title = type === 'heartbeat' ? (urlData.name || qndrsAhmL10n.heartbeat) : getHost(url);
    230325        const identityMeta = type === 'heartbeat'
    231             ? `<span>${escapeHtml((qndrsAhmL10n.expected_every_seconds || 'Expected every %d seconds').replace('%d', parseInt(urlData.heartbeat && urlData.heartbeat.expected_interval ? urlData.heartbeat.expected_interval : urlData.expected_interval, 10) || 300))}</span>`
    232             : `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`;
     326            ? `<span data-view-column="address">${escapeHtml((qndrsAhmL10n.expected_every_seconds || 'Expected every %d seconds').replace('%d', parseInt(urlData.heartbeat && urlData.heartbeat.expected_interval ? urlData.heartbeat.expected_interval : urlData.expected_interval, 10) || 300))}</span>`
     327            : `<a data-view-column="address" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`;
    233328        const averageResponse = dashboard.average_response_time_display
    234329            ? `<span class="uptime-response-average">${escapeHtml(dashboard.average_response_time_display)}</span>`
    235330            : '';
     331        const latest = dashboard.latest || {};
     332        const responseTime = parseInt(latest.response_time_ms, 10);
     333        const currentResponse = !isNaN(responseTime)
     334            ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.response_time_ms.replace('%d', responseTime))}</span>`
     335            : (averageResponse ? '' : `<span class="uptime-chip uptime-chip-muted">${escapeHtml(qndrsAhmL10n.no_data)}</span>`);
    236336
    237337        return `
    … …  
    243343                <div class="uptime-url-identity">
    244344                    <strong>${escapeHtml(title)}</strong>
    245                     <span class="uptime-chip uptime-chip-muted">${escapeHtml(type === 'heartbeat' ? qndrsAhmL10n.heartbeat : qndrsAhmL10n.http_check)}</span>
     345                    <span class="uptime-chip uptime-chip-muted" data-view-column="type">${escapeHtml(type === 'heartbeat' ? qndrsAhmL10n.heartbeat : qndrsAhmL10n.http_check)}</span>
    246346                    ${identityMeta}
    247347                </div>
    248                 <div class="uptime-url-current">
     348                <div class="uptime-url-current" data-view-column="check">
    249349                    ${renderCurrentCheck(dashboard)}
    250350                </div>
    251                 <div class="uptime-url-metrics">
     351                <div class="uptime-url-response" data-view-column="response">
     352                    ${currentResponse}
     353                    ${averageResponse}
     354                </div>
     355                <div class="uptime-url-metrics" data-view-column="uptime">
    252356                    ${renderUptimeSummary(urlData.uptime)}
    253                     ${averageResponse}
    254                 </div>
    255                 <div class="uptime-url-alerts">
     357                </div>
     358                <div class="uptime-url-alerts" data-view-column="alerts">
    256359                    <span class="uptime-chip${notificationsLabel === qndrsAhmL10n.no_alerts ? ' is-warning' : ''}">${escapeHtml(notificationsLabel)}</span>
     360                </div>
     361                <div class="uptime-url-incident" data-view-column="incident">
    257362                    <span class="uptime-chip${incidentOpen ? ' is-danger' : ' uptime-chip-muted'}">${escapeHtml(incidentLabel)}</span>
    258363                    ${incidentDuration ? `<span class="uptime-chip is-danger">${escapeHtml(incidentDuration)}</span>` : ''}
    … …  
    272377    function updateUrlList(urls) {
    273378        const list = $('.uptime-url-list');
     379        list.closest('.uptime-url-list-scroll').toggleClass('is-empty', !Array.isArray(urls) || urls.length === 0);
    274380        const expandedIds = [];
    275381
    … …  
    466572
    467573        $('.uptime-auto-refresh-input').prop('checked', isEnabled);
    468         window.localStorage.setItem(autoRefreshStorageKey, isEnabled ? '1' : '0');
     574        try {
     575            window.localStorage.setItem(autoRefreshStorageKey, isEnabled ? '1' : '0');
     576        } catch (error) {
     577            // Auto-refresh remains usable without persistent browser storage.
     578        }
    469579
    470580        if (isEnabled) {
    … …  
    511621    });
    512622
    513     if ($('.uptime-auto-refresh-input').length && window.localStorage.getItem(autoRefreshStorageKey) === '1') {
    514         setAutoRefresh(true);
     623    try {
     624        if ($('.uptime-auto-refresh-input').length && window.localStorage.getItem(autoRefreshStorageKey) === '1') {
     625            setAutoRefresh(true);
     626        }
     627    } catch (error) {
     628        // Keep manual refresh and all other controls available.
    515629    }
    516630
  • qndrs-availability-heartbeat-monitor/trunk/languages/qndrs-availability-heartbeat-monitor-nl_NL.l10n.php

    r3666118 r3763178  
    11<?php
    22return array (
    3   'project-id-version' => 'Qndrs Availability and Heartbeat Monitor 3.6.1',
     3  'project-id-version' => 'Qndrs Availability and Heartbeat Monitor 3.7.1',
    44  'report-msgid-bugs-to' => '',
    55  'po-revision-date' => '2026-08-26 12:00+0200',
    … …  
    2323    'A general error occurred. Please try again.' => 'Er is een algemene fout opgetreden. Probeer het opnieuw.',
    2424    'Across URLs with history' => 'Over URL\'s met geschiedenis',
     25    'Actions' => 'Acties',
    2526    'Active incidents: %1$d; active URLs: %2$d' => 'Actieve incidenten: %1$d; actieve URLs: %2$d',
    2627    'Add URL' => 'URL toevoegen',
    … …  
    3940    'Clear stored user key' => 'Opgeslagen gebruikerssleutel wissen',
    4041    'Clients send a POST request to this endpoint with their own heartbeat token.' => 'Clients sturen een POST naar dit endpoint met hun eigen heartbeat token.',
     42    'Color theme' => 'Kleurthema',
     43    'Columns' => 'Kolommen',
     44    'Compact overview' => 'Compact overzicht',
    4145    'Complete monitor status response.' => 'Reactie met de volledige monitorstatus.',
    4246    'Configuration Import / Export' => 'Configuratie importeren / exporteren',
    … …  
    5963    'Current settings' => 'Huidige instellingen',
    6064    'Current token ends with %s.' => 'Huidig token eindigt op %s.',
     65    'Dark' => 'Donker',
    6166    'Degraded' => 'Verminderd',
    6267    'Delete' => 'Verwijderen',
    … …  
    107112    'Incident active' => 'Incident actief',
    108113    'Incident open' => 'Incident open',
     114    'Incidents' => 'Incidenten',
    109115    'Interval:' => 'Interval:',
    110116    'Invalid JSON format.' => 'Ongeldige JSON format.',
    … …  
    114120    'Invalid form request.' => 'Ongeldige formulieraanvraag.',
    115121    'Invalid log date parameter: %s.' => 'Ongeldige datum parameter %s in log.',
     122    'Last check' => 'Laatste check',
     123    'Last check & HTTP status' => 'Laatste check & HTTP-status',
    116124    'Last check:' => 'Laatste check:',
    117125    'Last ping:' => 'Laatste ping:',
     126    'Light' => 'Licht',
    118127    'Logs' => 'Logs',
    119128    'Maximum wait time per HTTP request attempt.' => 'Maximale wachttijd per poging tot een HTTP-aanvraag.',
    120129    'Minimum 60 seconds' => 'Minimaal 60 seconden',
    121130    'Missing for %s' => 'Ontbrekend sinds %s',
     131    'Monitor' => 'Monitor',
    122132    'Monitor Interval (seconds)' => 'Monitorinterval (seconden)',
    123133    'Monitor interval' => 'Monitorinterval',
    124134    'Monitor status by ID.' => 'Status van een monitor op basis van ID.',
     135    'Monitor type' => 'Type monitor',
    125136    'Monitor website availability and receive alerts by email or Pushover. Manage multiple URLs from the WordPress admin with logging, JSON import/export, REST support, dashboard refresh and heartbeat monitors.' => 'Monitor de beschikbaarheid van sites en ontvang waarschuwingen via e-mail of Pushover. Beheer meerdere URL\'s vanuit WordPress-beheer met logging, JSON-import/-export, REST-ondersteuning, het verversen van het dashboard en heartbeat monitors.',
    126137    'Monitored URLs' => 'Gemonitorde URLs',
    … …  
    148159    'Outbound client pings' => 'Uitgaande client pings',
    149160    'Overall status' => 'Algemene status',
     161    'Overview' => 'Overzicht',
    150162    'Paste a previously exported JSON configuration here.' => 'Plak hier een eerder geëxporteerde JSON-configuratie.',
    151163    'Pause' => 'Pauzeren',
    … …  
    173185    'Request Timeout (seconds)' => 'Aanvraagtime-out (seconden)',
    174186    'Request timeout' => 'Aanvraagtime-out',
     187    'Response time' => 'Responstijd',
     188    'Response time & average' => 'Responstijd & gemiddelde',
     189    'Restore defaults' => 'Herstel standaard',
    175190    'Resume' => 'Hervatten',
    176191    'Retries:' => 'Pogingen:',
    … …  
    190205    'Slower' => 'Langzamer',
    191206    'Stable' => 'Stabiel',
     207    'Status' => 'Status',
    192208    'Status History' => 'Statusgeschiedenis',
     209    'Status and monitor name always remain visible.' => 'Status en monitornaam blijven altijd zichtbaar.',
    193210    'Status becomes stale/down after roughly twice this interval without a ping.' => 'De status wordt stale/down na ongeveer twee keer dit interval zonder ping.',
    194211    'Store Pushover credentials here when file access to wp-config.php is not available.' => 'Sla Pushover-toegangsgegevens hier op wanneer bestandstoegang tot wp-config.php niet beschikbaar is.',
     212    'System' => 'Systeem',
    195213    'The website %1$s is down. Error: %2$s.' => 'De site %1$s is down. Fout: %2$s.',
    196214    'The website %1$s is down. HTTP Status Code: %2$d.' => 'De site %1$s is down. HTTP-statuscode: %2$d.',
    … …  
    202220    'Token:' => 'Token:',
    203221    'URL' => 'URL',
     222    'URL / heartbeat interval' => 'URL / heartbeatinterval',
    204223    'URL added successfully!' => 'URL succesvol toegevoegd!',
    205224    'URL deleted successfully!' => 'URL succesvol verwijderd!',
    … …  
    214233    'Updated just now.' => 'Zojuist geüpdatet.',
    215234    'Uptime' => 'Uptime',
     235    'Uptime (24h, 7d, 30d)' => 'Uptime (24u, 7d, 30d)',
    216236    'Use comma-separated HTTP status codes or ranges. Default: 100-199,300-599.' => 'Gebruik komma-gescheiden HTTP-statuscodes of bereiken. Standaard: 100-199,300-599.',
    217237    'Use heartbeat monitors for computers, jobs, Home Assistant, NAS devices or internal apps that can reach this monitor through outbound HTTPS, but cannot be reached inbound.' => 'Gebruik heartbeat monitors voor computers, jobs, Home Assistant, NAS-apparaten of interne apps die deze monitor via uitgaande HTTPS kunnen bereiken, maar zelf niet van buitenaf bereikbaar zijn.',
  • qndrs-availability-heartbeat-monitor/trunk/languages/qndrs-availability-heartbeat-monitor-nl_NL.po

    r3666118 r3763178  
    11msgid ""
    22msgstr ""
    3 "Project-Id-Version: Qndrs Availability and Heartbeat Monitor 3.6.1\n"
     3"Project-Id-Version: Qndrs Availability and Heartbeat Monitor 3.7.1\n"
    44"Report-Msgid-Bugs-To: \n"
    55"PO-Revision-Date: 2026-08-26 12:00+0200\n"
    … …  
    985985msgid "Heartbeat monitor"
    986986msgstr "Heartbeat monitor"
     987
     988
     989#: qndrs-availability-heartbeat-monitor.php:2253
     990msgid "Actions"
     991msgstr "Acties"
     992
     993
     994#: qndrs-availability-heartbeat-monitor.php:2372
     995msgid "Color theme"
     996msgstr "Kleurthema"
     997
     998
     999#: qndrs-availability-heartbeat-monitor.php:2376
     1000msgid "Columns"
     1001msgstr "Kolommen"
     1002
     1003
     1004#: qndrs-availability-heartbeat-monitor.php:2392
     1005msgid "Compact overview"
     1006msgstr "Compact overzicht"
     1007
     1008
     1009#: qndrs-availability-heartbeat-monitor.php:2374
     1010msgid "Dark"
     1011msgstr "Donker"
     1012
     1013
     1014#: qndrs-availability-heartbeat-monitor.php:2252
     1015msgid "Incidents"
     1016msgstr "Incidenten"
     1017
     1018
     1019#: qndrs-availability-heartbeat-monitor.php:2248
     1020msgid "Last check"
     1021msgstr "Laatste check"
     1022
     1023
     1024#: qndrs-availability-heartbeat-monitor.php:2380
     1025msgid "Last check & HTTP status"
     1026msgstr "Laatste check & HTTP-status"
     1027
     1028
     1029#: qndrs-availability-heartbeat-monitor.php:2373
     1030msgid "Light"
     1031msgstr "Licht"
     1032
     1033
     1034#: qndrs-availability-heartbeat-monitor.php:2247
     1035msgid "Monitor"
     1036msgstr "Monitor"
     1037
     1038
     1039#: qndrs-availability-heartbeat-monitor.php:2385
     1040msgid "Monitor type"
     1041msgstr "Type monitor"
     1042
     1043
     1044#: qndrs-availability-heartbeat-monitor.php:2249
     1045msgid "Response time"
     1046msgstr "Responstijd"
     1047
     1048
     1049#: qndrs-availability-heartbeat-monitor.php:2381
     1050msgid "Response time & average"
     1051msgstr "Responstijd & gemiddelde"
     1052
     1053
     1054#: qndrs-availability-heartbeat-monitor.php:2393
     1055msgid "Restore defaults"
     1056msgstr "Herstel standaard"
     1057
     1058
     1059#: qndrs-availability-heartbeat-monitor.php:2246
     1060msgid "Status"
     1061msgstr "Status"
     1062
     1063
     1064#: qndrs-availability-heartbeat-monitor.php:2377
     1065msgid "Status and monitor name always remain visible."
     1066msgstr "Status en monitornaam blijven altijd zichtbaar."
     1067
     1068
     1069#: qndrs-availability-heartbeat-monitor.php:2375
     1070msgid "System"
     1071msgstr "Systeem"
     1072
     1073
     1074#: qndrs-availability-heartbeat-monitor.php:2386
     1075msgid "URL / heartbeat interval"
     1076msgstr "URL / heartbeatinterval"
     1077
     1078
     1079#: qndrs-availability-heartbeat-monitor.php:2382
     1080msgid "Uptime (24h, 7d, 30d)"
     1081msgstr "Uptime (24u, 7d, 30d)"
     1082
     1083#: qndrs-availability-heartbeat-monitor.php
     1084msgid "Overview"
     1085msgstr "Overzicht"
  • qndrs-availability-heartbeat-monitor/trunk/qndrs-availability-heartbeat-monitor.php

    r3666118 r3763178  
    44 * Plugin URI: https://github.com/qndrs/uptime-monitor
    55 * Description: Monitor website availability and receive alerts by email or Pushover. Manage multiple URLs from the WordPress admin with logging, JSON import/export, REST support, dashboard refresh and heartbeat monitors.
    6  * Version: 3.6.1
     6 * Version: 3.7.1
    77 * Author: Robert E. Kuunders, GPT
    88 * Author URI: https://qndrs.nl
    … …  
    3434class AvailabilityHeartbeatMonitor
    3535{
    36     public const VERSION = '3.6.1';
     36    public const VERSION = '3.7.1';
    3737    public const MAX_LOG_ENTRIES = 1000;
    3838    public const MAX_STATUS_HISTORY_PER_URL = 43200;
    … …  
    724724        add_submenu_page(
    725725            'qndrs-availability-heartbeat-monitor',
     726            __('Overview', 'qndrs-availability-heartbeat-monitor'),
     727            __('Overview', 'qndrs-availability-heartbeat-monitor'),
     728            'manage_options',
     729            'qndrs-availability-heartbeat-monitor',
     730            [$this, 'render_urls_page']
     731        );
     732        add_submenu_page(
     733            'qndrs-availability-heartbeat-monitor',
    726734            __('Settings', 'qndrs-availability-heartbeat-monitor'),
    727735            __('Settings', 'qndrs-availability-heartbeat-monitor'),
    … …  
    775783                'nonce' => wp_create_nonce('qndrs_ahm_nonce'),
    776784                'refresh_interval_ms' => 30000,
     785                'view_storage_key' => 'qndrsAhmView:v1:' . get_current_blog_id() . ':' . get_current_user_id(),
    777786            ]);
    778787            // Localize script for translations
    … …  
    819828                'refreshed' => __('Updated just now.', 'qndrs-availability-heartbeat-monitor'),
    820829                'auto_refresh' => __('Auto-refresh', 'qndrs-availability-heartbeat-monitor'),
     830                'no_data' => __('No data', 'qndrs-availability-heartbeat-monitor'),
    821831                'heartbeat' => __('Heartbeat', 'qndrs-availability-heartbeat-monitor'),
    822832                'http_check' => __('HTTP check', 'qndrs-availability-heartbeat-monitor'),
    … …  
    10861096            $url_history = isset($history[$url_id]) && is_array($history[$url_id]) ? $history[$url_id] : [];
    10871097            $url_history[] = $entry;
    1088             $history[$url_id] = $this->prune_status_history($url_history);
     1098            // Keep the verification snapshot intact until table storage is finalized.
     1099            $history[$url_id] = $storage_mode === self::HISTORY_STORAGE_TABLE_SHADOW
     1100                ? $url_history
     1101                : $this->prune_status_history($url_history);
    10891102            $this->update_status_history($history);
    10901103        }
    … …  
    15001513        $paused = 0;
    15011514        $degraded = 0;
     1515        $unknown = 0;
    15021516        $incident_count = 0;
    15031517        $email_count = 0;
    … …  
    15191533            } elseif ($status === 'degraded') {
    15201534                $degraded++;
     1535            } else {
     1536                $unknown++;
    15211537            }
    15221538
    … …  
    15481564        $average_response = !empty($response_times) ? (int)round(array_sum($response_times) / count($response_times)) : null;
    15491565        $average_uptime_24h = !empty($uptime_24h) ? round(array_sum($uptime_24h) / count($uptime_24h), 1) : null;
    1550         $overall_status = $down > 0 ? 'incident' : ($degraded > 0 ? 'degraded' : 'operational');
     1566        $overall_status = $down > 0 ? 'incident' : ($degraded > 0 ? 'degraded' : ($unknown > 0 || $active === 0 ? 'unknown' : 'operational'));
    15511567        $next_check = wp_next_scheduled(self::CRON_HOOK);
    15521568        $average_response_display = __('No data', 'qndrs-availability-heartbeat-monitor');
    … …  
    15641580            'paused' => $paused,
    15651581            'degraded' => $degraded,
     1582            'unknown' => $unknown,
    15661583            'incidents' => $incident_count,
    15671584            'email_count' => $email_count,
    … …  
    15781595            'overall_label' => $overall_status === 'incident'
    15791596                ? __('Incident active', 'qndrs-availability-heartbeat-monitor')
    1580                 : ($overall_status === 'degraded' ? __('Degraded', 'qndrs-availability-heartbeat-monitor') : __('Operational', 'qndrs-availability-heartbeat-monitor')),
     1597                : ($overall_status === 'degraded' ? __('Degraded', 'qndrs-availability-heartbeat-monitor') : ($overall_status === 'unknown' ? __('Unknown', 'qndrs-availability-heartbeat-monitor') : __('Operational', 'qndrs-availability-heartbeat-monitor'))),
    15811598        ];
    15821599    }
    … …  
    17271744        }
    17281745
    1729         $urls = $this->normalize_stored_urls();
    1730         $matched_index = $this->find_heartbeat_monitor_index_by_token($urls, $token);
    1731 
    1732         if ($matched_index === null) {
    1733             return new \WP_Error('qndrs_ahm_heartbeat_token_invalid', __('Heartbeat token is invalid.', 'qndrs-availability-heartbeat-monitor'), ['status' => 403]);
    1734         }
    1735 
    1736         $url_data = $urls[$matched_index];
    1737         if (isset($url_data['enabled']) && $url_data['enabled'] === false) {
    1738             return new \WP_Error('qndrs_ahm_heartbeat_disabled', __('Heartbeat monitor is disabled.', 'qndrs-availability-heartbeat-monitor'), ['status' => 403]);
    1739         }
    1740 
    17411746        $status = sanitize_key((string)$request->get_param('status'));
    17421747        if (!in_array($status, ['up', 'down', 'error'], true)) {
    … …  
    17461751        $message = sanitize_text_field((string)$request->get_param('message'));
    17471752        $timestamp = gmdate('Y-m-d H:i:s');
    1748         $url_data['last_seen'] = $timestamp;
    1749         $url_data['last_status'] = $status;
    1750         $url_data['last_message'] = $message;
    1751         $urls[$matched_index] = $url_data;
    1752         update_option('qndrs_ahm_urls', $urls);
     1753        $url_data = $this->persist_heartbeat($token, $status, $message, $timestamp);
     1754        if (is_wp_error($url_data)) {
     1755            return $url_data;
     1756        }
    17531757
    17541758        $history_message = $message !== '' ? $message : __('Heartbeat received.', 'qndrs-availability-heartbeat-monitor');
    … …  
    17741778            'last_seen' => $timestamp,
    17751779        ], 200);
     1780    }
     1781
     1782    private function persist_heartbeat(string $token, string $status, string $message, string $timestamp)
     1783    {
     1784        global $wpdb;
     1785
     1786        // Bypass the option cache: each retry must merge with the latest committed list.
     1787        for ($attempt = 0; $attempt < 5; $attempt++) {
     1788            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A compare-and-swap requires the exact current serialized option value.
     1789            $stored_value = $wpdb->get_var($wpdb->prepare(
     1790                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
     1791                'qndrs_ahm_urls'
     1792            ));
     1793            $urls = $this->normalize_urls(maybe_unserialize($stored_value));
     1794            $index = $this->find_heartbeat_monitor_index_by_token($urls, $token);
     1795            if ($index === null) {
     1796                return new \WP_Error('qndrs_ahm_heartbeat_token_invalid', __('Heartbeat token is invalid.', 'qndrs-availability-heartbeat-monitor'), ['status' => 403]);
     1797            }
     1798            if (!$urls[$index]['enabled']) {
     1799                return new \WP_Error('qndrs_ahm_heartbeat_disabled', __('Heartbeat monitor is disabled.', 'qndrs-availability-heartbeat-monitor'), ['status' => 403]);
     1800            }
     1801
     1802            $urls[$index]['last_seen'] = $timestamp;
     1803            $urls[$index]['last_status'] = $status;
     1804            $urls[$index]['last_message'] = $message;
     1805            $new_value = maybe_serialize($urls);
     1806            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic comparison prevents concurrent heartbeat updates from overwriting each other; invalidate option caches after success.
     1807            $updated = $wpdb->query($wpdb->prepare(
     1808                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = %s",
     1809                $new_value,
     1810                'qndrs_ahm_urls',
     1811                $stored_value
     1812            ));
     1813            if ($updated === false) {
     1814                break;
     1815            }
     1816            if ($updated === 1 || $new_value === $stored_value) {
     1817                wp_cache_delete('qndrs_ahm_urls', 'options');
     1818                wp_cache_delete('alloptions', 'options');
     1819                return $urls[$index];
     1820            }
     1821        }
     1822
     1823        return new \WP_Error('qndrs_ahm_heartbeat_write_failed', 'Heartbeat could not be saved. Please retry.', ['status' => 503]);
    17761824    }
    17771825
    … …  
    17931841                'paused' => $dashboard['paused'],
    17941842                'degraded' => $dashboard['degraded'],
     1843                'unknown' => $dashboard['unknown'],
    17951844                'incidents' => $dashboard['incidents'],
    17961845            ],
    … …  
    20422091    private function render_dashboard_overview(array $dashboard): void
    20432092    {
    2044         $overall_status = isset($dashboard['overall_status']) ? sanitize_key($dashboard['overall_status']) : 'operational';
    2045         $status_class = $overall_status === 'incident' ? 'down' : ($overall_status === 'degraded' ? 'degraded' : 'up');
     2093        $overall_status = isset($dashboard['overall_status']) ? sanitize_key($dashboard['overall_status']) : 'unknown';
     2094        $status_class = $overall_status === 'incident' ? 'down' : ($overall_status === 'operational' ? 'up' : $overall_status);
    20462095
    20472096        echo '<section class="uptime-statusbar is-' . esc_attr($status_class) . '" aria-label="' . esc_attr__('Monitoring summary', 'qndrs-availability-heartbeat-monitor') . '">';
    … …  
    20892138    private function render_dashboard_widget_content(array $dashboard): void
    20902139    {
    2091         $overall_status = isset($dashboard['overall_status']) ? sanitize_key($dashboard['overall_status']) : 'operational';
    2092         $status_class = $overall_status === 'incident' ? 'down' : ($overall_status === 'degraded' ? 'degraded' : 'up');
     2140        $overall_status = isset($dashboard['overall_status']) ? sanitize_key($dashboard['overall_status']) : 'unknown';
     2141        $status_class = $overall_status === 'incident' ? 'down' : ($overall_status === 'operational' ? 'up' : $overall_status);
    20932142        $attention_urls = array_values(array_filter($dashboard['urls'], function (array $url_data): bool {
    20942143            $url_dashboard = isset($url_data['dashboard']) && is_array($url_data['dashboard']) ? $url_data['dashboard'] : [];
    … …  
    21122161        echo '</div>';
    21132162
    2114         if (empty($attention_urls)) {
     2163        if (empty($attention_urls) && $overall_status === 'operational') {
    21152164            echo '<p class="uptime-widget-ok">' . esc_html__('All monitored URLs are reachable.', 'qndrs-availability-heartbeat-monitor') . '</p>';
    21162165        } else {
    … …  
    22012250    private function render_url_dashboard_rows(array $urls): void
    22022251    {
     2252        echo '<div class="uptime-url-list-scroll' . (empty($urls) ? ' is-empty' : '') . '" tabindex="0" role="region" aria-label="' . esc_attr__('Monitored URLs', 'qndrs-availability-heartbeat-monitor') . '">';
     2253        echo '<div class="uptime-url-column-head" aria-hidden="true">';
     2254        echo '<span>' . esc_html__('Status', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2255        echo '<span>' . esc_html__('Monitor', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2256        echo '<span data-view-column="check">' . esc_html__('Last check', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2257        echo '<span data-view-column="response">' . esc_html__('Response time', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2258        echo '<span data-view-column="uptime">' . esc_html__('Uptime', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2259        echo '<span data-view-column="alerts">' . esc_html__('Notifications', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2260        echo '<span data-view-column="incident">' . esc_html__('Incidents', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2261        echo '<span>' . esc_html__('Actions', 'qndrs-availability-heartbeat-monitor') . '</span></div>';
    22032262        echo '<section class="uptime-url-list" aria-label="' . esc_attr__('Monitored URLs', 'qndrs-availability-heartbeat-monitor') . '">';
    22042263        if (empty($urls)) {
    … …  
    22102269        }
    22112270        echo '</section>';
     2271        echo '</div>';
    22122272    }
    22132273
    … …  
    22462306        echo '<div class="uptime-url-identity">';
    22472307        echo '<strong>' . esc_html($title) . '</strong>';
    2248         echo '<span class="uptime-chip uptime-chip-muted">' . esc_html($type === 'heartbeat' ? __('Heartbeat', 'qndrs-availability-heartbeat-monitor') : __('HTTP check', 'qndrs-availability-heartbeat-monitor')) . '</span>';
     2308        echo '<span class="uptime-chip uptime-chip-muted" data-view-column="type">' . esc_html($type === 'heartbeat' ? __('Heartbeat', 'qndrs-availability-heartbeat-monitor') : __('HTTP check', 'qndrs-availability-heartbeat-monitor')) . '</span>';
    22492309        if ($type !== 'heartbeat' && $url !== '') {
    2250             echo '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
     2310            echo '<a data-view-column="address" href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
    22512311        } elseif ($type === 'heartbeat' && !empty($url_data['expected_interval'])) {
    22522312            /* translators: %d: Expected heartbeat interval in seconds. */
    2253             echo '<span>' . esc_html(sprintf(__('Expected every %d seconds', 'qndrs-availability-heartbeat-monitor'), absint($url_data['expected_interval']))) . '</span>';
    2254         }
    2255         echo '</div>';
    2256 
    2257         echo '<div class="uptime-url-current">';
     2313            echo '<span data-view-column="address">' . esc_html(sprintf(__('Expected every %d seconds', 'qndrs-availability-heartbeat-monitor'), absint($url_data['expected_interval']))) . '</span>';
     2314        }
     2315        echo '</div>';
     2316
     2317        echo '<div class="uptime-url-current" data-view-column="check">';
    22582318        if ($latest !== null) {
    22592319            $status_code = isset($latest['status_code']) && $latest['status_code'] !== null ? absint($latest['status_code']) : 0;
    2260             $response_time_ms = isset($latest['response_time_ms']) && $latest['response_time_ms'] !== null ? absint($latest['response_time_ms']) : null;
    22612320            $timestamp = isset($latest['timestamp']) ? (string)$latest['timestamp'] : '';
    22622321            $display_timestamp = isset($latest['display_timestamp']) ? (string)$latest['display_timestamp'] : $timestamp;
    … …  
    22662325                echo '<span class="uptime-chip">' . esc_html(sprintf(__('HTTP %d', 'qndrs-availability-heartbeat-monitor'), $status_code)) . '</span>';
    22672326            }
    2268             if ($response_time_ms !== null) {
    2269                 /* translators: %d: Response time in milliseconds. */
    2270                 echo '<span class="uptime-chip">' . esc_html(sprintf(__('%d ms', 'qndrs-availability-heartbeat-monitor'), $response_time_ms)) . '</span>';
    2271             }
    22722327            if ($timestamp !== '') {
    22732328                echo '<time class="uptime-chip uptime-chip-muted" datetime="' . esc_attr($timestamp) . '">' . esc_html($display_timestamp) . '</time>';
    … …  
    22782333        echo '</div>';
    22792334
    2280         echo '<div class="uptime-url-metrics">';
    2281         $this->render_uptime_summary($uptime);
     2335        echo '<div class="uptime-url-response" data-view-column="response">';
     2336        if ($latest !== null && isset($latest['response_time_ms'])) {
     2337            /* translators: %d: Response time in milliseconds. */
     2338            echo '<span class="uptime-chip">' . esc_html(sprintf(__('%d ms', 'qndrs-availability-heartbeat-monitor'), absint($latest['response_time_ms']))) . '</span>';
     2339        } elseif (empty($dashboard['average_response_time_display'])) {
     2340            echo '<span class="uptime-chip uptime-chip-muted">' . esc_html__('No data', 'qndrs-availability-heartbeat-monitor') . '</span>';
     2341        }
    22822342        if (!empty($dashboard['average_response_time_display'])) {
    22832343            echo '<span class="uptime-response-average">' . esc_html($dashboard['average_response_time_display']) . '</span>';
    … …  
    22852345        echo '</div>';
    22862346
    2287         echo '<div class="uptime-url-alerts">';
     2347        echo '<div class="uptime-url-metrics" data-view-column="uptime">';
     2348        $this->render_uptime_summary($uptime);
     2349        echo '</div>';
     2350
     2351        echo '<div class="uptime-url-alerts" data-view-column="alerts">';
    22882352        echo '<span class="uptime-chip' . ($notifications_label === __('No alerts', 'qndrs-availability-heartbeat-monitor') ? ' is-warning' : '') . '">' . esc_html($notifications_label) . '</span>';
     2353        echo '</div>';
     2354        echo '<div class="uptime-url-incident" data-view-column="incident">';
    22892355        echo '<span class="uptime-chip' . ($incident_open ? ' is-danger' : ' uptime-chip-muted') . '">' . esc_html($incident_label) . '</span>';
    22902356        if ($incident_duration_display !== '') {
    … …  
    23052371    }
    23062372
    2307     /**
    2308      * Renders the admin page for managing monitored URLs.
    2309      */
     2373    /** Renders browser-local overview preferences; no configuration is submitted. */
     2374    private function render_view_controls(): void
     2375    {
     2376        echo '<div class="uptime-view-controls" hidden>';
     2377        echo '<div class="uptime-theme-switch" role="group" aria-label="' . esc_attr__('Color theme', 'qndrs-availability-heartbeat-monitor') . '">';
     2378        echo '<button type="button" data-theme-choice="light" aria-pressed="true">' . esc_html__('Light', 'qndrs-availability-heartbeat-monitor') . '</button>';
     2379        echo '<button type="button" data-theme-choice="dark" aria-pressed="false">' . esc_html__('Dark', 'qndrs-availability-heartbeat-monitor') . '</button>';
     2380        echo '<button type="button" data-theme-choice="system" aria-pressed="false">' . esc_html__('System', 'qndrs-availability-heartbeat-monitor') . '</button></div>';
     2381        echo '<details class="uptime-column-settings"><summary class="button">' . esc_html__('Columns', 'qndrs-availability-heartbeat-monitor') . ' <span class="uptime-column-count" aria-live="polite" aria-atomic="true">7/7</span></summary>';
     2382        echo '<div class="uptime-column-panel"><p>' . esc_html__('Status and monitor name always remain visible.', 'qndrs-availability-heartbeat-monitor') . '</p>';
     2383        echo '<fieldset><legend class="screen-reader-text">' . esc_html__('Columns', 'qndrs-availability-heartbeat-monitor') . '</legend>';
     2384        $columns = [
     2385            'check' => __('Last check & HTTP status', 'qndrs-availability-heartbeat-monitor'),
     2386            'response' => __('Response time & average', 'qndrs-availability-heartbeat-monitor'),
     2387            'uptime' => __('Uptime (24h, 7d, 30d)', 'qndrs-availability-heartbeat-monitor'),
     2388            'alerts' => __('Notifications', 'qndrs-availability-heartbeat-monitor'),
     2389            'incident' => __('Incidents', 'qndrs-availability-heartbeat-monitor'),
     2390            'type' => __('Monitor type', 'qndrs-availability-heartbeat-monitor'),
     2391            'address' => __('URL / heartbeat interval', 'qndrs-availability-heartbeat-monitor'),
     2392        ];
     2393        foreach ($columns as $key => $label) {
     2394            echo '<label><input type="checkbox" data-view-toggle="' . esc_attr($key) . '" checked> ' . esc_html($label) . '</label>';
     2395        }
     2396        echo '</fieldset><div class="uptime-column-panel-actions">';
     2397        echo '<button type="button" class="button uptime-view-compact">' . esc_html__('Compact overview', 'qndrs-availability-heartbeat-monitor') . '</button>';
     2398        echo '<button type="button" class="button uptime-view-reset">' . esc_html__('Restore defaults', 'qndrs-availability-heartbeat-monitor') . '</button></div></div></details></div>';
     2399    }
     2400
     2401    /** Renders the admin page for managing monitored URLs. */
    23102402    public function render_urls_page(): void
    23112403    {
    … …  
    23742466        $urls = $dashboard['urls'];
    23752467
    2376         echo '<div class="wrap uptime-monitor-admin uptime-monitor-dashboard">';
     2468        echo '<div class="wrap uptime-monitor-admin uptime-monitor-dashboard uptime-monitor-overview">';
    23772469        echo '<div class="uptime-dashboard-header">';
    23782470        echo '<h1>' . esc_html__('Qndrs Monitor', 'qndrs-availability-heartbeat-monitor') . '</h1>';
    … …  
    23832475        echo '</div>';
    23842476        echo '</div>';
     2477        $this->render_view_controls();
    23852478        echo '<div class="uptime-monitor-notices" aria-live="polite" aria-atomic="true"></div>';
    23862479
    … …  
    31813274        global $wpdb;
    31823275
     3276        if ($this->get_history_storage_mode() === self::HISTORY_STORAGE_TABLE) {
     3277            return ['success' => false, 'message' => 'History storage is already finalized; refusing to replace table history.'];
     3278        }
     3279
    31833280        $batch_size = min(2000, max(50, $batch_size));
    31843281        $this->create_history_table();
    … …  
    31863283        update_option('qndrs_ahm_data_schema_version', self::DATA_SCHEMA_VERSION, false);
    31873284        $history = $this->get_status_history();
     3285        // Legacy is authoritative until verification completes. Rebuild the shadow
     3286        // copy so retention, rollback and interrupted imports cannot leave extra rows.
     3287        update_option('qndrs_ahm_history_storage', self::HISTORY_STORAGE_LEGACY, false);
     3288        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared -- Only the unfinalized shadow copy is cleared; legacy history is retained for recovery.
     3289        if ($wpdb->query('DELETE FROM ' . $this->get_history_table_name()) === false) {
     3290            return ['success' => false, 'message' => 'Could not reset shadow history; legacy storage remains active.'];
     3291        }
    31883292        $batch = [];
    31893293        $valid_entries = 0;
  • qndrs-availability-heartbeat-monitor/trunk/readme.txt

    r3666118 r3763178  
    55Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 3.6.1
     7Stable tag: 3.7.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1515Qndrs Availability and Heartbeat Monitor checks external URLs from the WordPress dashboard and can also monitor firewalled clients through outbound heartbeat pings. It supports multiple monitored URLs, per-URL monitoring toggles, email alerts, configurable Pushover alerts, status history, response times, uptime percentages, JSON import/export, structured logging, heartbeat monitors, and authenticated REST endpoints.
    1616
     17The monitor overview supports light, dark and system themes, configurable columns and a compact view. Display preferences are remembered in this browser for each site and WordPress user, including during automatic refresh. Status, monitor names and management actions remain available.
     18
    1719== Installation ==
    1820
    19211. Upload the plugin folder to `/wp-content/plugins/qndrs-availability-heartbeat-monitor`.
    20222. Activate Qndrs Availability and Heartbeat Monitor from the WordPress plugins page.
    21 3. Open Qndrs Monitor in the WordPress admin menu.
     233. Open Qndrs Monitor > Overview in the WordPress admin menu. Use Qndrs Monitor > Settings for configuration.
    22244. Add one or more URLs and choose notification channels.
    2325
    … …  
    6971
    7072== Changelog ==
     73
     74= 3.7.1 =
     75
     76* Renamed the first submenu to Overview to distinguish it from the Qndrs Monitor parent menu; Settings remains the configuration page.
     77
     78= 3.7.0 =
     79
     80* Added light, dark and system themes with configurable overview columns and a compact view.
     81* Preserved display preferences and expanded history during automatic refresh.
     82* Fixed shadow-history migration recovery, unknown aggregate status and concurrent heartbeat persistence.
     83* Added Dutch translations for the new display controls.
    7184
    7285= 3.6.1 =
  • qndrs-availability-heartbeat-monitor/trunk/uptime-monitor.css

    r3608284 r3763178  
    1818}
    1919
     20/* Overview preferences are deliberately scoped away from settings and the widget. */
     21.uptime-monitor-overview {
     22    --ump-columns: 90px minmax(190px, 1.4fr) minmax(150px, .85fr) minmax(90px, .5fr) minmax(190px, 1fr) minmax(100px, .6fr) minmax(100px, .6fr) 340px;
     23    --ump-list-min-width: 1386px;
     24    --ump-soft: #f6f7f7;
     25    --ump-link: #2271b1;
     26    --ump-shadow: 0 12px 34px rgba(0, 0, 0, .16);
     27}
     28
     29.uptime-monitor-overview [hidden] { display: none !important; }
     30.uptime-view-controls { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; margin: 12px 0; }
     31.uptime-theme-switch { background: var(--ump-panel); border: 1px solid var(--ump-border); border-radius: 6px; display: flex; padding: 3px; }
     32.uptime-theme-switch button { background: transparent; border: 0; border-radius: 3px; color: var(--ump-muted); cursor: pointer; font: inherit; padding: 6px 12px; }
     33.uptime-theme-switch button[aria-pressed="true"] { background: var(--ump-soft); color: var(--ump-text); box-shadow: 0 1px 3px rgba(0, 0, 0, .15); }
     34.uptime-monitor-overview :is(button, summary, input, a):focus-visible { outline: 2px solid var(--ump-link); outline-offset: 2px; }
     35.uptime-column-settings { position: relative; }
     36.uptime-column-settings > summary { cursor: pointer; list-style: none; }
     37.uptime-column-settings > summary::-webkit-details-marker { display: none; }
     38.uptime-column-settings[open] > summary { border-color: var(--ump-link); }
     39.uptime-column-count { border-left: 1px solid var(--ump-border); color: var(--ump-muted); font-size: 11px; margin-left: 6px; padding-left: 8px; }
     40.uptime-column-panel { background: var(--ump-panel); border: 1px solid var(--ump-border); border-radius: 6px; box-shadow: var(--ump-shadow); box-sizing: border-box; max-height: 65vh; overflow-y: auto; padding: 16px; position: absolute; right: 0; top: calc(100% + 8px); width: 310px; z-index: 20; }
     41.uptime-column-panel p { color: var(--ump-muted); font-size: 12px; margin: 0 0 10px; }
     42.uptime-column-panel fieldset { border: 0; margin: 0; min-width: 0; padding: 0; }
     43.uptime-column-panel label { align-items: center; cursor: pointer; display: flex; gap: 9px; padding: 7px 0; }
     44.uptime-column-panel input[type="checkbox"] { flex: 0 0 auto; margin: 0; }
     45.uptime-column-panel-actions { border-top: 1px solid var(--ump-border); display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding-top: 12px; }
     46.uptime-column-panel-actions .button { flex: 1 1 auto; }
     47.uptime-url-list-scroll { overflow-x: auto; padding: 2px 2px 8px; scrollbar-color: var(--ump-border) transparent; }
     48.uptime-url-column-head { align-items: center; box-sizing: border-box; color: var(--ump-muted); display: grid; font-size: 11px; font-weight: 600; gap: 12px 14px; grid-template-columns: var(--ump-columns); min-width: var(--ump-list-min-width); padding: 8px 17px 8px 21px; }
     49.uptime-monitor-overview .uptime-url-list { min-width: var(--ump-list-min-width); }
     50.uptime-url-list-scroll.is-empty .uptime-url-column-head { display: none; }
     51.uptime-monitor-overview .uptime-url-list-scroll.is-empty .uptime-url-list { min-width: 0; }
     52.uptime-monitor-overview .uptime-url-row { gap: 12px 14px; grid-template-columns: var(--ump-columns); }
     53.uptime-monitor-overview .uptime-url-actions { justify-content: flex-end; }
     54.uptime-url-response, .uptime-url-incident { align-items: center; display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; }
     55.uptime-hide-check [data-view-column="check"],
     56.uptime-hide-response [data-view-column="response"],
     57.uptime-hide-uptime [data-view-column="uptime"],
     58.uptime-hide-alerts [data-view-column="alerts"],
     59.uptime-hide-incident [data-view-column="incident"],
     60.uptime-hide-type [data-view-column="type"],
     61.uptime-hide-address [data-view-column="address"] { display: none !important; }
     62
     63.uptime-monitor-overview[data-theme="dark"] {
     64    --ump-bg: #10161f;
     65    --ump-panel: #19222e;
     66    --ump-soft: #202c3a;
     67    --ump-border: #344152;
     68    --ump-text: #e7edf5;
     69    --ump-muted: #a9b6c7;
     70    --ump-up: #6ad79a;
     71    --ump-down: #ff9d9d;
     72    --ump-paused: #a9b6c7;
     73    --ump-warning: #f0c36d;
     74    --ump-info: #9bbcff;
     75    --ump-link: #9bbcff;
     76    --ump-shadow: 0 12px 34px rgba(0, 0, 0, .4);
     77    background: var(--ump-bg);
     78    color-scheme: dark;
     79}
     80body.qndrs-ahm-dark-overview #wpcontent,
     81body.qndrs-ahm-dark-overview #wpfooter { background: #10161f; }
     82body.qndrs-ahm-dark-overview #wpfooter { color: #a9b6c7; }
     83body.qndrs-ahm-dark-overview #wpfooter a { color: #9bbcff; }
     84.uptime-monitor-overview[data-theme="dark"] :is(h1, h2, h3, label) { color: var(--ump-text); }
     85.uptime-monitor-overview[data-theme="dark"] a { color: var(--ump-link); }
     86.uptime-monitor-overview[data-theme="dark"] .button { background: var(--ump-panel); border-color: var(--ump-border); color: var(--ump-link); }
     87.uptime-monitor-overview[data-theme="dark"] .button:hover { background: var(--ump-soft); border-color: var(--ump-link); }
     88.uptime-monitor-overview[data-theme="dark"] .button-primary { background: #5269ee; border-color: #5269ee; color: #fff; }
     89.uptime-monitor-overview[data-theme="dark"] .button-primary:hover { background: #4359d1; border-color: #4359d1; }
     90.uptime-monitor-overview[data-theme="dark"] .button:disabled { background: var(--ump-soft) !important; border-color: var(--ump-border) !important; color: var(--ump-muted) !important; }
     91.uptime-monitor-overview[data-theme="dark"] :is(input[type="url"], input[type="text"], input[type="checkbox"]) { background: var(--ump-bg); border-color: var(--ump-muted); color: var(--ump-text); }
     92.uptime-monitor-overview[data-theme="dark"] input::placeholder { color: var(--ump-muted); }
     93.uptime-monitor-overview[data-theme="dark"] .uptime-chip { background: var(--ump-soft); border-color: var(--ump-border); color: var(--ump-text); }
     94.uptime-monitor-overview[data-theme="dark"] .uptime-chip-muted { color: var(--ump-muted); }
     95.uptime-monitor-overview[data-theme="dark"] .uptime-chip.is-warning { background: #382e1d; border-color: #706044; color: var(--ump-warning); }
     96.uptime-monitor-overview[data-theme="dark"] .uptime-chip.is-danger { background: #382329; border-color: #70404b; color: var(--ump-down); }
     97.uptime-monitor-overview[data-theme="dark"] .uptime-percentage { background: var(--ump-soft); border-color: var(--ump-border); }
     98.uptime-monitor-overview[data-theme="dark"] .uptime-percentage-label { background: #2b3747; color: var(--ump-muted); }
     99.uptime-monitor-overview[data-theme="dark"] .uptime-percentage-value { color: var(--ump-up); }
     100.uptime-monitor-overview[data-theme="dark"] :is(.uptime-response-average, .uptime-history-response-time) { background: #20364a; color: #acdcff; }
     101.uptime-monitor-overview[data-theme="dark"] :is(.uptime-history-empty, .uptime-history-time, .uptime-history-message) { color: var(--ump-muted); }
     102.uptime-monitor-overview[data-theme="dark"] .uptime-history-code { color: var(--ump-text); }
     103.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-up, .uptime-response-trend-faster) { background: #213b30; color: var(--ump-up); }
     104.uptime-monitor-overview[data-theme="dark"] .uptime-status-down { background: #44282f; color: var(--ump-down); }
     105.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-error, .uptime-status-degraded, .uptime-response-trend-slower) { background: #382e1d; color: var(--ump-warning); }
     106.uptime-monitor-overview[data-theme="dark"] :is(.uptime-status-paused, .uptime-status-unknown, .uptime-response-trend-stable) { background: var(--ump-soft); color: var(--ump-muted); }
     107.uptime-monitor-overview[data-theme="dark"] .uptime-delete-button { border-color: #70404b; color: var(--ump-down); }
     108.uptime-monitor-overview[data-theme="dark"] .uptime-delete-button:hover { background: #44282f; border-color: var(--ump-down); }
     109.uptime-monitor-overview[data-theme="dark"] .uptime-monitor-notice { background: var(--ump-panel); color: var(--ump-text); }
     110.uptime-monitor-overview[data-theme="dark"] .notice-dismiss::before { color: var(--ump-muted); }
     111
     112@media (max-width: 782px) {
     113    .uptime-monitor-overview .uptime-dashboard-header { align-items: flex-start; flex-direction: column; }
     114    .uptime-view-controls { justify-content: space-between; }
     115    .uptime-column-settings { margin-left: auto; }
     116    .uptime-column-panel { max-width: calc(100vw - 40px); }
     117}
     118
    20119.uptime-widget-refresh-meter {
    21120    background: rgba(34, 113, 177, 0.12);
    … …  
    62161    border-left-color: var(--ump-warning);
    63162    background: rgba(183, 121, 31, 0.07);
     163}
     164
     165.uptime-dashboard-widget.is-unknown .uptime-widget-status {
     166    border-left-color: var(--ump-paused);
    64167}
    65168
    … …  
    219322.uptime-statusbar.is-degraded {
    220323    border-left-color: var(--ump-warning);
     324}
     325
     326.uptime-statusbar.is-unknown {
     327    border-left-color: var(--ump-paused);
    221328}
    222329
  • qndrs-availability-heartbeat-monitor/trunk/uptime-monitor.js

    r3651690 r3763178  
    66    let widgetRefreshTimer = null;
    77    let widgetRefreshRequest = null;
     8    const overview = $('.uptime-monitor-overview');
     9    const viewPreferences = { theme: 'light', hidden: [] };
     10    const viewColumns = {
     11        check: { track: 'minmax(150px, .85fr)', width: 164 },
     12        response: { track: 'minmax(90px, .5fr)', width: 104 },
     13        uptime: { track: 'minmax(190px, 1fr)', width: 204 },
     14        alerts: { track: 'minmax(100px, .6fr)', width: 114 },
     15        incident: { track: 'minmax(100px, .6fr)', width: 114 }
     16    };
     17    const optionalViewFields = [...Object.keys(viewColumns), 'type', 'address'];
     18    const systemTheme = window.matchMedia('(prefers-color-scheme: dark)');
     19
     20    function applyViewTheme() {
     21        if (!overview.length) {
     22            return;
     23        }
     24        const theme = viewPreferences.theme === 'system' ? (systemTheme.matches ? 'dark' : 'light') : viewPreferences.theme;
     25        overview.attr('data-theme', theme);
     26        $('body').toggleClass('qndrs-ahm-dark-overview', theme === 'dark');
     27        overview.find('[data-theme-choice]').each(function () {
     28            $(this).attr('aria-pressed', String(this.dataset.themeChoice === viewPreferences.theme));
     29        });
     30    }
     31
     32    function applyViewColumns() {
     33        if (!overview.length) {
     34            return;
     35        }
     36        const visible = Object.keys(viewColumns).filter(key => !viewPreferences.hidden.includes(key));
     37        overview[0].style.setProperty('--ump-columns', [
     38            '90px', 'minmax(190px, 1.4fr)', ...visible.map(key => viewColumns[key].track), '340px'
     39        ].join(' '));
     40        overview[0].style.setProperty('--ump-list-min-width', (686 + visible.reduce((width, key) => width + viewColumns[key].width, 0)) + 'px');
     41        optionalViewFields.forEach(function (key) {
     42            const hidden = viewPreferences.hidden.includes(key);
     43            overview.toggleClass('uptime-hide-' + key, hidden);
     44            overview.find('[data-view-toggle="' + key + '"]').prop('checked', !hidden);
     45        });
     46        overview.find('.uptime-column-count').text((optionalViewFields.length - viewPreferences.hidden.length) + '/' + optionalViewFields.length);
     47    }
     48
     49    function saveViewPreferences() {
     50        try {
     51            window.localStorage.setItem(qndrsAhmAjax.view_storage_key, JSON.stringify(viewPreferences));
     52        } catch (error) {
     53            // Preferences still work for this page when browser storage is unavailable.
     54        }
     55    }
     56
     57    function initializeViewPreferences() {
     58        if (!overview.length) {
     59            return;
     60        }
     61        try {
     62            const saved = JSON.parse(window.localStorage.getItem(qndrsAhmAjax.view_storage_key));
     63            if (saved && ['light', 'dark', 'system'].includes(saved.theme)) {
     64                viewPreferences.theme = saved.theme;
     65            }
     66            if (saved && Array.isArray(saved.hidden)) {
     67                viewPreferences.hidden = optionalViewFields.filter(key => saved.hidden.includes(key));
     68            }
     69        } catch (error) {
     70            // Corrupt or blocked storage must not prevent monitoring controls from working.
     71        }
     72        applyViewTheme();
     73        applyViewColumns();
     74        overview.find('.uptime-view-controls').prop('hidden', false);
     75        overview.on('click', '[data-theme-choice]', function () {
     76            viewPreferences.theme = this.dataset.themeChoice;
     77            applyViewTheme();
     78            saveViewPreferences();
     79        });
     80        overview.on('change', '[data-view-toggle]', function () {
     81            viewPreferences.hidden = optionalViewFields.filter(key => !overview.find('[data-view-toggle="' + key + '"]').prop('checked'));
     82            applyViewColumns();
     83            saveViewPreferences();
     84        });
     85        overview.on('click', '.uptime-view-compact, .uptime-view-reset', function () {
     86            viewPreferences.hidden = $(this).hasClass('uptime-view-compact') ? ['check', 'alerts', 'incident', 'type', 'address'] : [];
     87            applyViewColumns();
     88            saveViewPreferences();
     89        });
     90        $(document).on('click.qndrsAhmView', function (event) {
     91            const settings = overview.find('.uptime-column-settings');
     92            if (!settings[0].contains(event.target)) {
     93                settings.prop('open', false);
     94            }
     95        }).on('keydown.qndrsAhmView', function (event) {
     96            const settings = overview.find('.uptime-column-settings');
     97            if (event.key === 'Escape' && settings.prop('open')) {
     98                settings.prop('open', false).find('summary').trigger('focus');
     99            }
     100        });
     101        systemTheme.addEventListener('change', applyViewTheme);
     102    }
     103
     104    initializeViewPreferences();
    8105
    9106    function escapeHtml(value) {
    … …  
    205302
    206303        const statusCode = parseInt(latest.status_code, 10);
    207         const responseTime = parseInt(latest.response_time_ms, 10);
    208304        const timestamp = latest.timestamp || '';
    209305        const displayTimestamp = latest.display_timestamp || timestamp;
    … …  
    211307        return `
    212308            ${statusCode ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.http_status.replace('%d', statusCode))}</span>` : ''}
    213             ${!isNaN(responseTime) ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.response_time_ms.replace('%d', responseTime))}</span>` : ''}
    214309            ${timestamp ? `<time class="uptime-chip uptime-chip-muted" datetime="${escapeHtml(timestamp)}">${escapeHtml(displayTimestamp)}</time>` : ''}
    215310        `;
    … …  
    229324        const title = type === 'heartbeat' ? (urlData.name || qndrsAhmL10n.heartbeat) : getHost(url);
    230325        const identityMeta = type === 'heartbeat'
    231             ? `<span>${escapeHtml((qndrsAhmL10n.expected_every_seconds || 'Expected every %d seconds').replace('%d', parseInt(urlData.heartbeat && urlData.heartbeat.expected_interval ? urlData.heartbeat.expected_interval : urlData.expected_interval, 10) || 300))}</span>`
    232             : `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`;
     326            ? `<span data-view-column="address">${escapeHtml((qndrsAhmL10n.expected_every_seconds || 'Expected every %d seconds').replace('%d', parseInt(urlData.heartbeat && urlData.heartbeat.expected_interval ? urlData.heartbeat.expected_interval : urlData.expected_interval, 10) || 300))}</span>`
     327            : `<a data-view-column="address" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`;
    233328        const averageResponse = dashboard.average_response_time_display
    234329            ? `<span class="uptime-response-average">${escapeHtml(dashboard.average_response_time_display)}</span>`
    235330            : '';
     331        const latest = dashboard.latest || {};
     332        const responseTime = parseInt(latest.response_time_ms, 10);
     333        const currentResponse = !isNaN(responseTime)
     334            ? `<span class="uptime-chip">${escapeHtml(qndrsAhmL10n.response_time_ms.replace('%d', responseTime))}</span>`
     335            : (averageResponse ? '' : `<span class="uptime-chip uptime-chip-muted">${escapeHtml(qndrsAhmL10n.no_data)}</span>`);
    236336
    237337        return `
    … …  
    243343                <div class="uptime-url-identity">
    244344                    <strong>${escapeHtml(title)}</strong>
    245                     <span class="uptime-chip uptime-chip-muted">${escapeHtml(type === 'heartbeat' ? qndrsAhmL10n.heartbeat : qndrsAhmL10n.http_check)}</span>
     345                    <span class="uptime-chip uptime-chip-muted" data-view-column="type">${escapeHtml(type === 'heartbeat' ? qndrsAhmL10n.heartbeat : qndrsAhmL10n.http_check)}</span>
    246346                    ${identityMeta}
    247347                </div>
    248                 <div class="uptime-url-current">
     348                <div class="uptime-url-current" data-view-column="check">
    249349                    ${renderCurrentCheck(dashboard)}
    250350                </div>
    251                 <div class="uptime-url-metrics">
     351                <div class="uptime-url-response" data-view-column="response">
     352                    ${currentResponse}
     353                    ${averageResponse}
     354                </div>
     355                <div class="uptime-url-metrics" data-view-column="uptime">
    252356                    ${renderUptimeSummary(urlData.uptime)}
    253                     ${averageResponse}
    254                 </div>
    255                 <div class="uptime-url-alerts">
     357                </div>
     358                <div class="uptime-url-alerts" data-view-column="alerts">
    256359                    <span class="uptime-chip${notificationsLabel === qndrsAhmL10n.no_alerts ? ' is-warning' : ''}">${escapeHtml(notificationsLabel)}</span>
     360                </div>
     361                <div class="uptime-url-incident" data-view-column="incident">
    257362                    <span class="uptime-chip${incidentOpen ? ' is-danger' : ' uptime-chip-muted'}">${escapeHtml(incidentLabel)}</span>
    258363                    ${incidentDuration ? `<span class="uptime-chip is-danger">${escapeHtml(incidentDuration)}</span>` : ''}
    … …  
    272377    function updateUrlList(urls) {
    273378        const list = $('.uptime-url-list');
     379        list.closest('.uptime-url-list-scroll').toggleClass('is-empty', !Array.isArray(urls) || urls.length === 0);
    274380        const expandedIds = [];
    275381
    … …  
    466572
    467573        $('.uptime-auto-refresh-input').prop('checked', isEnabled);
    468         window.localStorage.setItem(autoRefreshStorageKey, isEnabled ? '1' : '0');
     574        try {
     575            window.localStorage.setItem(autoRefreshStorageKey, isEnabled ? '1' : '0');
     576        } catch (error) {
     577            // Auto-refresh remains usable without persistent browser storage.
     578        }
    469579
    470580        if (isEnabled) {
    … …  
    511621    });
    512622
    513     if ($('.uptime-auto-refresh-input').length && window.localStorage.getItem(autoRefreshStorageKey) === '1') {
    514         setAutoRefresh(true);
     623    try {
     624        if ($('.uptime-auto-refresh-input').length && window.localStorage.getItem(autoRefreshStorageKey) === '1') {
     625            setAutoRefresh(true);
     626        }
     627    } catch (error) {
     628        // Keep manual refresh and all other controls available.
    515629    }
    516630
Note: See TracChangeset for help on using the changeset viewer.