Plugin Directory

Changeset 3799692


Ignore:
Timestamp:
11/13/2026 05:12:46 AM (2 hours ago)
Author:
aaronhsieh
Message:

Release 1.10.0: tabbed dashboard, lighter page loads, uninstall cleanup

Location:
site-add-on-watchdog/trunk
Files:
2 added
18 edited

Legend:

Unmodified
Added
Removed
  • site-add-on-watchdog/trunk/CHANGELOG.md

    r3770137 r3799692  
    55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
    66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     7
     8## [1.10.0] - 2026-11-13
     9
     10### Added
     11
     12- Split the dashboard into Risks, History, Settings, and Delivery tabs. The overview now shows when the last scan ran and when the next one is due.
     13- Check plugins that have no WordPress.org directory entry against WPScan when an API key is configured. The "not in the directory" answer is cached for six hours.
     14- Delete the plugin's settings, saved risks, ignore list, scan history, notification queue, and scheduled events when the plugin is deleted from the Plugins screen.
     15- Add copy buttons for the server cron endpoint, secret, and command.
     16
     17### Changed
     18
     19- Take the dashboard accent color from the WordPress admin, so it matches the admin color scheme on WordPress 6 and 7.
     20- Declare `Tested up to` only in `readme.txt`, as the current Plugin Check requires.
     21- Verify the packaged plugin against WordPress 7.1.4.
     22- Hide a notification channel's fields while the channel is switched off. Saved values are kept.
     23- Show the site-wide WP-Cron notice only after the scheduled scan is overdue. Sites that disable WP-Cron and run it from a system cron no longer see a permanent error.
     24- Remove the "Retry notifications" button that appeared when WP-Cron was disabled. It did the same thing as "Send notifications now".
     25
     26### Fixed
     27
     28- Turn email alerts on by default, as the readme has always described. A new install saved the setting as off. Sites that never saved the Watchdog settings will start emailing risk alerts to their administrators after this update; sites that saved the settings keep their choice.
     29- Stop writing the cron status option and running two option deletes on every page load. A front-end request now makes one plugin query instead of five.
     30- Stop looking up administrator accounts before `plugins_loaded` on a new install, which logged a WordPress notice in debug mode.
     31- Leave the recipient field empty when the site owner empties it. It was refilled with the administrator addresses, at the cost of a user query on every request. Administrators are still included when an alert is sent.
     32- Remove a plugin from the risk table as soon as it is ignored. It used to stay listed until the next scan.
     33- Show every column of the risk table on narrow screens. Only the plugin name was visible.
     34- Send the report again when a REST cron scan is requested with `force`, even if nothing changed. The parameter was previously ignored for scans.
     35- Stop repeating an earlier notice after a later action or a page reload.
     36- Keep the "Download payload" button usable after the first download.
     37- Stop recording a diagnostic failure on every scan for plugins that have no WordPress.org directory entry.
    738
    839## [1.9.0] - 2026-10-28
  • site-add-on-watchdog/trunk/assets/css/admin.css

    r3690952 r3799692  
    11.wp-watchdog-admin {
    2     --watchdog-brand: #135e96;
    3     --watchdog-brand-strong: #0a4b78;
    4     --watchdog-ink: #1d2327;
    5     --watchdog-muted: #50575e;
    6     --watchdog-border: #dcdcde;
    7     --watchdog-canvas: #f6f7f7;
    8     max-width: 1440px;
    9 }
    10 
    11 .wp-watchdog-admin * {
    12     box-sizing: border-box;
    13 }
    14 
    15 .wp-watchdog-admin .wp-watchdog-hero {
    16     display: flex;
    17     align-items: center;
    18     justify-content: space-between;
    19     gap: 24px;
    20     margin: 20px 0 0;
    21     padding: 24px 28px;
     2  --watchdog-accent: #2271b1;
     3  --watchdog-accent-strong: #135e96;
     4  --watchdog-accent-soft: #f0f6fc;
     5  --watchdog-ink: #1d2327;
     6  --watchdog-muted: #50575e;
     7  --watchdog-border: #dcdcde;
     8  --watchdog-divider: #f0f0f1;
     9  --watchdog-canvas: #f6f7f7;
     10  --watchdog-ok: #007017;
     11  --watchdog-ok-soft: #edfaef;
     12  --watchdog-warn: #7a5a00;
     13  --watchdog-warn-soft: #fcf5dc;
     14  --watchdog-danger: #b32d2e;
     15  --watchdog-danger-soft: #fcf0f1;
     16  --watchdog-radius: 12px;
     17  max-width: 1200px;
     18  color: var(--watchdog-ink);
     19}
     20
     21/* Keep the tint in step when the script swaps in the admin accent color. */
     22@supports (color: color-mix(in srgb, red, blue)) {
     23  .wp-watchdog-admin {
     24    --watchdog-accent-soft: color-mix(in srgb, var(--watchdog-accent) 9%, #fff);
     25  }
     26}
     27
     28.wp-watchdog-admin *,
     29.wp-watchdog-admin *::before,
     30.wp-watchdog-admin *::after {
     31  box-sizing: border-box;
     32}
     33
     34/* Buttons with an icon */
     35.wp-watchdog-admin .button {
     36  display: inline-flex;
     37  align-items: center;
     38  gap: 6px;
     39}
     40
     41.wp-watchdog-admin .button .dashicons {
     42  width: 16px;
     43  height: 16px;
     44  font-size: 16px;
     45}
     46
     47.wp-watchdog-actions {
     48  display: flex;
     49  align-items: center;
     50  flex-wrap: wrap;
     51  gap: 8px;
     52}
     53
     54/* Header */
     55.wp-watchdog-header {
     56  display: flex;
     57  align-items: center;
     58  justify-content: space-between;
     59  flex-wrap: wrap;
     60  gap: 16px 24px;
     61  margin: 20px 0 16px;
     62  padding: 20px 24px;
     63  background: #fff;
     64  border: 1px solid var(--watchdog-border);
     65  border-radius: var(--watchdog-radius);
     66}
     67
     68.wp-watchdog-header__brand {
     69  display: flex;
     70  align-items: center;
     71  gap: 16px;
     72  min-width: 0;
     73}
     74
     75.wp-watchdog-header__icon {
     76  display: flex;
     77  flex: none;
     78  align-items: center;
     79  justify-content: center;
     80  width: 48px;
     81  height: 48px;
     82  color: #fff;
     83  background: var(--watchdog-accent);
     84  border-radius: var(--watchdog-radius);
     85  font-size: 28px;
     86}
     87
     88.wp-watchdog-admin .wp-watchdog-header__title {
     89  display: flex;
     90  align-items: center;
     91  flex-wrap: wrap;
     92  gap: 4px 10px;
     93  margin: 0;
     94  padding: 0;
     95  font-size: 22px;
     96  font-weight: 600;
     97  line-height: 1.3;
     98}
     99
     100.wp-watchdog-header__version {
     101  padding: 2px 8px;
     102  color: var(--watchdog-muted);
     103  background: var(--watchdog-canvas);
     104  border: 1px solid var(--watchdog-border);
     105  border-radius: 999px;
     106  font-size: 12px;
     107  font-weight: 500;
     108  line-height: 1.5;
     109}
     110
     111.wp-watchdog-header__summary {
     112  margin: 4px 0 0;
     113  color: var(--watchdog-muted);
     114  font-size: 14px;
     115}
     116
     117.wp-watchdog-header .button {
     118  min-height: 36px;
     119  padding: 0 14px;
     120}
     121
     122/* Overview tiles */
     123.wp-watchdog-overview {
     124  display: grid;
     125  grid-template-columns: repeat(4, minmax(0, 1fr));
     126  gap: 12px;
     127  margin: 16px 0 0;
     128}
     129
     130.wp-watchdog-stat {
     131  display: grid;
     132  grid-template-columns: auto minmax(0, 1fr);
     133  align-content: start;
     134  gap: 2px 12px;
     135  padding: 16px;
     136  color: var(--watchdog-ink);
     137  background: #fff;
     138  border: 1px solid var(--watchdog-border);
     139  border-radius: var(--watchdog-radius);
     140  text-decoration: none;
     141  transition:
     142    border-color 120ms ease,
     143    box-shadow 120ms ease;
     144}
     145
     146.wp-watchdog-stat:hover,
     147.wp-watchdog-stat:focus {
     148  color: var(--watchdog-ink);
     149  border-color: var(--watchdog-accent);
     150  box-shadow: 0 0 0 1px var(--watchdog-accent);
     151  outline: none;
     152}
     153
     154.wp-watchdog-stat__icon {
     155  display: flex;
     156  grid-row: 1 / span 3;
     157  align-items: center;
     158  justify-content: center;
     159  width: 36px;
     160  height: 36px;
     161  color: var(--watchdog-accent);
     162  background: var(--watchdog-accent-soft);
     163  border-radius: 10px;
     164  font-size: 20px;
     165}
     166
     167.wp-watchdog-stat__label {
     168  color: var(--watchdog-muted);
     169  font-size: 12px;
     170  font-weight: 600;
     171}
     172
     173.wp-watchdog-stat__value {
     174  font-size: 24px;
     175  font-weight: 600;
     176  line-height: 1.25;
     177}
     178
     179.wp-watchdog-stat__value--text {
     180  font-size: 18px;
     181  line-height: 1.65;
     182}
     183
     184.wp-watchdog-stat__hint {
     185  overflow: hidden;
     186  color: var(--watchdog-muted);
     187  font-size: 12px;
     188  text-overflow: ellipsis;
     189}
     190
     191.wp-watchdog-stat--ok .wp-watchdog-stat__icon {
     192  color: var(--watchdog-ok);
     193  background: var(--watchdog-ok-soft);
     194}
     195
     196.wp-watchdog-stat--warn .wp-watchdog-stat__icon {
     197  color: var(--watchdog-warn);
     198  background: var(--watchdog-warn-soft);
     199}
     200
     201.wp-watchdog-stat--danger .wp-watchdog-stat__icon {
     202  color: var(--watchdog-danger);
     203  background: var(--watchdog-danger-soft);
     204}
     205
     206.wp-watchdog-stat--danger .wp-watchdog-stat__hint {
     207  color: var(--watchdog-danger);
     208  font-weight: 600;
     209}
     210
     211/* Section tabs */
     212.wp-watchdog-tabs {
     213  display: flex;
     214  gap: 4px;
     215  margin: 20px 0 16px;
     216  overflow-x: auto;
     217  border-bottom: 1px solid var(--watchdog-border);
     218}
     219
     220.wp-watchdog-tabs__tab {
     221  display: inline-flex;
     222  align-items: center;
     223  gap: 8px;
     224  margin-bottom: -1px;
     225  padding: 10px 14px;
     226  color: var(--watchdog-muted);
     227  border-bottom: 2px solid transparent;
     228  font-size: 14px;
     229  font-weight: 600;
     230  text-decoration: none;
     231  white-space: nowrap;
     232}
     233
     234.wp-watchdog-tabs__tab:hover {
     235  color: var(--watchdog-ink);
     236}
     237
     238.wp-watchdog-tabs__tab:focus {
     239  color: var(--watchdog-ink);
     240  box-shadow: inset 0 0 0 2px var(--watchdog-accent);
     241  outline: none;
     242}
     243
     244.wp-watchdog-tabs__tab[aria-current="true"] {
     245  color: var(--watchdog-accent);
     246  border-bottom-color: var(--watchdog-accent);
     247}
     248
     249.wp-watchdog-tabs__count {
     250  min-width: 20px;
     251  padding: 0 6px;
     252  color: #fff;
     253  background: var(--watchdog-danger);
     254  border-radius: 999px;
     255  font-size: 11px;
     256  line-height: 20px;
     257  text-align: center;
     258}
     259
     260.wp-watchdog-tabs__dot {
     261  width: 8px;
     262  height: 8px;
     263  background: var(--watchdog-danger);
     264  border-radius: 50%;
     265}
     266
     267.wp-watchdog-panel {
     268  scroll-margin-top: 48px;
     269}
     270
     271.wp-watchdog-panel[hidden] {
     272  display: none;
     273}
     274
     275.wp-watchdog-admin--js .wp-watchdog-no-js {
     276  display: none;
     277}
     278
     279/* Cards */
     280.wp-watchdog-card {
     281  margin: 0 0 16px;
     282  padding: 20px 24px;
     283  background: #fff;
     284  border: 1px solid var(--watchdog-border);
     285  border-radius: var(--watchdog-radius);
     286}
     287
     288.wp-watchdog-card__header {
     289  display: flex;
     290  align-items: flex-start;
     291  justify-content: space-between;
     292  flex-wrap: wrap;
     293  gap: 8px 16px;
     294}
     295
     296.wp-watchdog-admin .wp-watchdog-card__title {
     297  margin: 0;
     298  padding: 0;
     299  color: var(--watchdog-ink);
     300  font-size: 16px;
     301  font-weight: 600;
     302  line-height: 1.4;
     303}
     304
     305.wp-watchdog-admin .wp-watchdog-card__description {
     306  max-width: 76ch;
     307  margin: 4px 0 0;
     308  color: var(--watchdog-muted);
     309  font-size: 13px;
     310}
     311
     312.wp-watchdog-card-grid {
     313  display: grid;
     314  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
     315  gap: 16px;
     316}
     317
     318.wp-watchdog-card-grid .wp-watchdog-card {
     319  margin: 0;
     320}
     321
     322.wp-watchdog-card .wp-watchdog-actions {
     323  margin-top: 16px;
     324}
     325
     326.wp-watchdog-error-text {
     327  margin: 8px 0 0;
     328  padding: 8px 12px;
     329  overflow-wrap: anywhere;
     330  color: var(--watchdog-danger);
     331  background: var(--watchdog-danger-soft);
     332  border-radius: 6px;
     333  font-size: 13px;
     334}
     335
     336/* Badges */
     337.wp-watchdog-badge {
     338  display: inline-flex;
     339  flex: none;
     340  align-items: center;
     341  padding: 2px 10px;
     342  color: var(--watchdog-muted);
     343  background: var(--watchdog-canvas);
     344  border-radius: 999px;
     345  font-size: 12px;
     346  font-weight: 600;
     347  line-height: 1.6;
     348  white-space: nowrap;
     349}
     350
     351.wp-watchdog-badge--ok {
     352  color: var(--watchdog-ok);
     353  background: var(--watchdog-ok-soft);
     354}
     355
     356.wp-watchdog-badge--warn {
     357  color: var(--watchdog-warn);
     358  background: var(--watchdog-warn-soft);
     359}
     360
     361.wp-watchdog-badge--danger {
     362  color: var(--watchdog-danger);
     363  background: var(--watchdog-danger-soft);
     364}
     365
     366/* Empty state */
     367.wp-watchdog-empty {
     368  padding: 32px 16px 16px;
     369  text-align: center;
     370}
     371
     372.wp-watchdog-empty__icon {
     373  display: inline-flex;
     374  align-items: center;
     375  justify-content: center;
     376  width: 56px;
     377  height: 56px;
     378  color: var(--watchdog-ok);
     379  background: var(--watchdog-ok-soft);
     380  border-radius: 50%;
     381  font-size: 28px;
     382}
     383
     384.wp-watchdog-empty__icon.dashicons-search {
     385  color: var(--watchdog-accent);
     386  background: var(--watchdog-accent-soft);
     387}
     388
     389.wp-watchdog-admin .wp-watchdog-empty__title {
     390  margin: 12px 0 0;
     391  font-size: 15px;
     392  font-weight: 600;
     393}
     394
     395.wp-watchdog-admin .wp-watchdog-empty__text {
     396  margin: 4px 0 0;
     397  color: var(--watchdog-muted);
     398}
     399
     400/* Risk table */
     401.wp-watchdog-risk-table {
     402  margin-top: 16px;
     403}
     404
     405.wp-watchdog-risk-table__controls {
     406  display: flex;
     407  align-items: center;
     408  justify-content: space-between;
     409  flex-wrap: wrap;
     410  gap: 8px 16px;
     411  margin-bottom: 12px;
     412}
     413
     414.wp-watchdog-risk-table__filter-form {
     415  display: flex;
     416  align-items: center;
     417  flex-wrap: wrap;
     418  gap: 8px;
     419}
     420
     421.wp-watchdog-risk-table__search input[type="search"] {
     422  width: 260px;
     423  max-width: 100%;
     424}
     425
     426.wp-watchdog-risk-table__pagination {
     427  display: flex;
     428  align-items: center;
     429  gap: 8px;
     430}
     431
     432.wp-watchdog-risk-table__pagination .button {
     433  justify-content: center;
     434  min-width: 32px;
     435}
     436
     437.wp-watchdog-risk-table__page-status {
     438  color: var(--watchdog-muted);
     439  font-size: 13px;
     440}
     441
     442.wp-watchdog-risk-table__scroll {
     443  overflow-x: auto;
     444  border: 1px solid var(--watchdog-border);
     445  border-radius: 8px;
     446}
     447
     448.wp-watchdog-risk-table__scroll table.widefat {
     449  margin: 0;
     450  border: 0;
     451  box-shadow: none;
     452}
     453
     454.wp-watchdog-risk-table table.widefat th,
     455.wp-watchdog-risk-table table.widefat td {
     456  padding: 12px;
     457  vertical-align: top;
     458}
     459
     460.wp-watchdog-risk-table__col-plugin {
     461  width: 24%;
     462}
     463
     464.wp-watchdog-risk-table__col-local,
     465.wp-watchdog-risk-table__col-remote {
     466  width: 13%;
     467}
     468
     469.wp-watchdog-risk-table__col-actions {
     470  width: 90px;
     471}
     472
     473.wp-watchdog-risk-table__sort {
     474  display: inline-flex;
     475  align-items: center;
     476  gap: 6px;
     477  padding: 0;
     478  color: inherit;
     479  background: none;
     480  border: 0;
     481  font: inherit;
     482  font-weight: 600;
     483  text-align: left;
     484  cursor: pointer;
     485}
     486
     487.wp-watchdog-risk-table__sort:focus-visible {
     488  border-radius: 2px;
     489  outline: 2px solid var(--watchdog-accent);
     490  outline-offset: 2px;
     491}
     492
     493.wp-watchdog-risk-table__sort-indicator {
     494  width: 0;
     495  height: 0;
     496  border-top: 6px solid currentColor;
     497  border-right: 4px solid transparent;
     498  border-left: 4px solid transparent;
     499  opacity: 0.3;
     500  transition:
     501    transform 0.2s ease,
     502    opacity 0.2s ease;
     503}
     504
     505.wp-watchdog-risk-table__sort[aria-sort="ascending"]
     506  .wp-watchdog-risk-table__sort-indicator {
     507  opacity: 1;
     508  transform: rotate(180deg);
     509}
     510
     511.wp-watchdog-risk-table__sort[aria-sort="descending"]
     512  .wp-watchdog-risk-table__sort-indicator {
     513  opacity: 1;
     514}
     515
     516.wp-watchdog-admin .wp-watchdog-reasons {
     517  margin: 0;
     518  padding-left: 18px;
     519  list-style: disc;
     520}
     521
     522.wp-watchdog-admin .wp-watchdog-reasons li {
     523  margin-bottom: 4px;
     524}
     525
     526.wp-watchdog-admin .wp-watchdog-vulnerabilities {
     527  display: grid;
     528  gap: 6px;
     529  margin: 8px 0 0;
     530  padding: 10px 12px;
     531  background: var(--watchdog-canvas);
     532  border-radius: 6px;
     533  list-style: none;
     534}
     535
     536.wp-watchdog-admin .wp-watchdog-vulnerabilities li {
     537  display: flex;
     538  align-items: baseline;
     539  flex-wrap: wrap;
     540  gap: 4px 8px;
     541  margin: 0;
     542}
     543
     544.wp-watchdog-vulnerability__meta {
     545  color: var(--watchdog-muted);
     546  font-size: 12px;
     547}
     548
     549.wp-watchdog-severity {
     550  display: inline-flex;
     551  align-items: center;
     552  padding: 1px 8px;
     553  border-radius: 999px;
     554  font-size: 11px;
     555  font-weight: 600;
     556  letter-spacing: 0.04em;
     557  text-transform: uppercase;
     558}
     559
     560.wp-watchdog-severity--low {
     561  color: #1c5f3a;
     562  background: #e7f7ed;
     563}
     564
     565.wp-watchdog-severity--medium {
     566  color: #7a5a00;
     567  background: #fff4d6;
     568}
     569
     570.wp-watchdog-severity--high {
     571  color: #922424;
     572  background: #fde4df;
     573}
     574
     575.wp-watchdog-severity--severe {
     576  color: #fff;
     577  background: #b32d2e;
     578}
     579
     580/* Ignored plugins */
     581.wp-watchdog-admin .wp-watchdog-chips {
     582  display: flex;
     583  flex-wrap: wrap;
     584  gap: 8px;
     585  margin: 12px 0 0;
     586  padding: 0;
     587  list-style: none;
     588}
     589
     590.wp-watchdog-admin .wp-watchdog-chips li {
     591  margin: 0;
     592}
     593
     594.wp-watchdog-chip {
     595  display: inline-flex;
     596  align-items: center;
     597  gap: 4px;
     598  padding: 4px 6px 4px 12px;
     599  color: var(--watchdog-ink);
     600  background: var(--watchdog-canvas);
     601  border: 1px solid var(--watchdog-border);
     602  border-radius: 999px;
     603  font-size: 13px;
     604  line-height: 1.5;
     605  cursor: pointer;
     606}
     607
     608.wp-watchdog-chip:hover,
     609.wp-watchdog-chip:focus-visible {
     610  color: var(--watchdog-danger);
     611  border-color: var(--watchdog-danger);
     612  outline: none;
     613}
     614
     615.wp-watchdog-chip .dashicons {
     616  width: 16px;
     617  height: 16px;
     618  font-size: 16px;
     619}
     620
     621/* History */
     622.wp-watchdog-admin .wp-watchdog-history {
     623  margin: 16px 0 0;
     624  padding: 0;
     625  border: 1px solid var(--watchdog-border);
     626  border-radius: 8px;
     627  list-style: none;
     628}
     629
     630.wp-watchdog-admin .wp-watchdog-history__item {
     631  display: flex;
     632  align-items: center;
     633  flex-wrap: wrap;
     634  gap: 8px 16px;
     635  margin: 0;
     636  padding: 10px 14px;
     637  border-top: 1px solid var(--watchdog-divider);
     638}
     639
     640.wp-watchdog-admin .wp-watchdog-history__item:first-child {
     641  border-top: 0;
     642}
     643
     644.wp-watchdog-history__date {
     645  display: inline-flex;
     646  flex: 1 1 220px;
     647  align-items: center;
     648  gap: 8px;
     649  font-weight: 600;
     650}
     651
     652.wp-watchdog-history__date .dashicons {
     653  color: var(--watchdog-muted);
     654}
     655
     656.wp-watchdog-history__downloads {
     657  display: flex;
     658  flex-wrap: wrap;
     659  gap: 8px;
     660}
     661
     662/* Settings */
     663.wp-watchdog-field {
     664  display: grid;
     665  grid-template-columns: 220px minmax(0, 1fr);
     666  gap: 8px 24px;
     667  padding: 16px 0;
     668  border-top: 1px solid var(--watchdog-divider);
     669}
     670
     671.wp-watchdog-card__title + .wp-watchdog-field,
     672.wp-watchdog-card__title
     673  + [data-watchdog-frequency-options]
     674  > .wp-watchdog-field:first-child {
     675  margin-top: 12px;
     676}
     677
     678.wp-watchdog-card > .wp-watchdog-field:last-child {
     679  padding-bottom: 0;
     680}
     681
     682.wp-watchdog-field__label {
     683  padding-top: 5px;
     684  font-weight: 600;
     685}
     686
     687.wp-watchdog-field__inline {
     688  display: flex;
     689  flex-wrap: wrap;
     690  gap: 8px;
     691}
     692
     693.wp-watchdog-admin .wp-watchdog-field .description,
     694.wp-watchdog-admin .wp-watchdog-channel .description {
     695  max-width: 76ch;
     696  margin: 6px 0 0;
     697}
     698
     699.wp-watchdog-settings-form input.regular-text {
     700  width: 100%;
     701  max-width: 560px;
     702}
     703
     704.wp-watchdog-admin .wp-watchdog-frequency-description--testing {
     705  color: var(--watchdog-danger);
     706  font-weight: 600;
     707}
     708
     709.wp-watchdog-channels {
     710  margin-top: 16px;
     711  border: 1px solid var(--watchdog-border);
     712  border-radius: 8px;
     713}
     714
     715.wp-watchdog-channel + .wp-watchdog-channel {
     716  border-top: 1px solid var(--watchdog-border);
     717}
     718
     719.wp-watchdog-channel__header {
     720  display: flex;
     721  align-items: center;
     722  gap: 12px;
     723  padding: 12px 16px;
     724}
     725
     726.wp-watchdog-channel__icon {
     727  display: flex;
     728  flex: none;
     729  align-items: center;
     730  justify-content: center;
     731  width: 32px;
     732  height: 32px;
     733  color: var(--watchdog-accent);
     734  background: var(--watchdog-accent-soft);
     735  border-radius: 8px;
     736}
     737
     738.wp-watchdog-admin .wp-watchdog-channel__title {
     739  flex: 1 1 auto;
     740  margin: 0;
     741  font-size: 14px;
     742  font-weight: 600;
     743}
     744
     745.wp-watchdog-channel__body {
     746  padding: 0 16px 16px 60px;
     747}
     748
     749.wp-watchdog-channel__body[hidden] {
     750  display: none;
     751}
     752
     753.wp-watchdog-admin .wp-watchdog-channel__field {
     754  margin: 0 0 12px;
     755}
     756
     757.wp-watchdog-channel__field label {
     758  display: block;
     759  margin-bottom: 4px;
     760  font-weight: 600;
     761}
     762
     763.wp-watchdog-admin .wp-watchdog-channel__actions {
     764  margin: 12px 0 0;
     765}
     766
     767/* Switch */
     768.wp-watchdog-switch {
     769  display: inline-flex;
     770  flex: none;
     771  align-items: center;
     772  gap: 8px;
     773  cursor: pointer;
     774}
     775
     776.wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"] {
     777  position: relative;
     778  width: 36px;
     779  min-width: 36px;
     780  height: 20px;
     781  margin: 0;
     782  background: #8c8f94;
     783  border: 0;
     784  border-radius: 999px;
     785  box-shadow: none;
     786  transition: background-color 120ms ease;
     787  appearance: none;
     788}
     789
     790.wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"]::before {
     791  position: absolute;
     792  top: 2px;
     793  left: 2px;
     794  float: none;
     795  width: 16px;
     796  height: 16px;
     797  margin: 0;
     798  background: #fff;
     799  border-radius: 50%;
     800  content: "";
     801  transition: transform 120ms ease;
     802}
     803
     804.wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"]:checked {
     805  background: var(--watchdog-accent);
     806}
     807
     808.wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"]:checked::before {
     809  content: "";
     810  transform: translateX(16px);
     811}
     812
     813.wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"]:focus-visible {
     814  outline: 2px solid var(--watchdog-accent);
     815  outline-offset: 2px;
     816}
     817
     818.wp-watchdog-switch__state {
     819  min-width: 24px;
     820  color: var(--watchdog-muted);
     821  font-size: 12px;
     822  font-weight: 600;
     823}
     824
     825.wp-watchdog-switch__state::after {
     826  content: attr(data-off);
     827}
     828
     829.wp-watchdog-switch input:checked + .wp-watchdog-switch__state {
     830  color: var(--watchdog-accent);
     831}
     832
     833.wp-watchdog-switch input:checked + .wp-watchdog-switch__state::after {
     834  content: attr(data-on);
     835}
     836
     837.wp-watchdog-admin .wp-watchdog-settings-form__submit {
     838  position: sticky;
     839  z-index: 2;
     840  bottom: 0;
     841  margin: 0;
     842  padding: 12px 0;
     843  background: #f0f0f1;
     844  border-top: 1px solid var(--watchdog-border);
     845}
     846
     847/* Server cron details */
     848.wp-watchdog-details {
     849  margin-top: 16px;
     850  padding-top: 12px;
     851  border-top: 1px solid var(--watchdog-divider);
     852}
     853
     854.wp-watchdog-details summary {
     855  color: var(--watchdog-accent);
     856  font-weight: 600;
     857  cursor: pointer;
     858}
     859
     860.wp-watchdog-details__body {
     861  margin-top: 12px;
     862}
     863
     864.wp-watchdog-copy {
     865  display: grid;
     866  grid-template-columns: minmax(0, 1fr) auto;
     867  align-items: center;
     868  gap: 4px 8px;
     869  margin-bottom: 12px;
     870}
     871
     872.wp-watchdog-copy__label {
     873  grid-column: 1 / -1;
     874  font-weight: 600;
     875}
     876
     877.wp-watchdog-copy code {
     878  display: block;
     879  padding: 8px 10px;
     880  overflow-wrap: anywhere;
     881  background: var(--watchdog-canvas);
     882  border: 1px solid var(--watchdog-border);
     883  border-radius: 6px;
     884  font-size: 12px;
     885}
     886
     887/* Submitting state */
     888.wp-watchdog-admin form.is-busy {
     889  opacity: 0.72;
     890  pointer-events: none;
     891}
     892
     893.wp-watchdog-admin form.is-busy button[type="submit"] .dashicons {
     894  animation: watchdog-spin 0.8s linear infinite;
     895}
     896
     897@keyframes watchdog-spin {
     898  to {
     899    transform: rotate(360deg);
     900  }
     901}
     902
     903@media (prefers-reduced-motion: reduce) {
     904  .wp-watchdog-admin *,
     905  .wp-watchdog-admin *::before {
     906    transition: none !important;
     907    animation: none !important;
     908  }
     909}
     910
     911@media (max-width: 960px) {
     912  .wp-watchdog-overview {
     913    grid-template-columns: repeat(2, minmax(0, 1fr));
     914  }
     915}
     916
     917@media (max-width: 782px) {
     918  .wp-watchdog-admin {
     919    margin-right: 10px;
     920  }
     921
     922  .wp-watchdog-header,
     923  .wp-watchdog-card {
     924    padding: 16px;
     925  }
     926
     927  .wp-watchdog-header .wp-watchdog-actions,
     928  .wp-watchdog-header .wp-watchdog-actions form {
     929    flex: 1 1 100%;
     930  }
     931
     932  .wp-watchdog-header .wp-watchdog-actions .button {
     933    justify-content: center;
     934    width: 100%;
     935  }
     936
     937  .wp-watchdog-field {
     938    grid-template-columns: minmax(0, 1fr);
     939  }
     940
     941  .wp-watchdog-field__label {
     942    padding-top: 0;
     943  }
     944
     945  .wp-watchdog-channel__body {
     946    padding-left: 16px;
     947  }
     948
     949  .wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"] {
     950    width: 44px;
     951    min-width: 44px;
     952    height: 24px;
     953  }
     954
     955  .wp-watchdog-admin .wp-watchdog-switch input[type="checkbox"]::before {
     956    width: 20px;
     957    height: 20px;
     958  }
     959
     960  .wp-watchdog-admin
     961    .wp-watchdog-switch
     962    input[type="checkbox"]:checked::before {
     963    margin: 0;
     964    transform: translateX(20px);
     965  }
     966
     967  .wp-watchdog-risk-table__search,
     968  .wp-watchdog-risk-table__search input[type="search"] {
     969    width: 100%;
     970  }
     971
     972  /* Each risk becomes a card. */
     973  .wp-watchdog-risk-table__scroll {
     974    border: 0;
     975  }
     976
     977  .wp-watchdog-risk-table__scroll table,
     978  .wp-watchdog-risk-table__scroll tbody,
     979  .wp-watchdog-risk-table__scroll td {
     980    display: block;
     981  }
     982
     983  .wp-watchdog-risk-table__scroll thead {
     984    position: absolute;
     985    width: 1px;
     986    height: 1px;
     987    margin: -1px;
     988    padding: 0;
    22989    overflow: hidden;
    23     color: #fff;
    24     background:
    25         radial-gradient(circle at 88% 10%, rgba(255, 255, 255, 0.2), transparent 28%),
    26         linear-gradient(135deg, var(--watchdog-brand-strong), var(--watchdog-brand));
    27     border-radius: 14px;
    28     box-shadow: 0 8px 24px rgba(10, 75, 120, 0.16);
    29 }
    30 
    31 .wp-watchdog-hero__content {
    32     max-width: 760px;
    33 }
    34 
    35 .wp-watchdog-hero__eyebrow {
    36     display: flex;
    37     align-items: center;
    38     gap: 8px;
    39     margin-bottom: 8px;
    40     color: rgba(255, 255, 255, 0.78);
    41     font-size: 12px;
    42     font-weight: 600;
    43     letter-spacing: 0.05em;
    44     text-transform: uppercase;
    45 }
    46 
    47 .wp-watchdog-admin .wp-watchdog-hero__title {
    48     display: flex;
    49     align-items: center;
    50     gap: 10px;
    51     margin: 0;
    52     color: #fff;
    53     font-size: clamp(24px, 3vw, 34px);
    54     line-height: 1.2;
    55 }
    56 
    57 .wp-watchdog-hero__title .dashicons {
    58     width: 36px;
    59     height: 36px;
    60     font-size: 36px;
    61 }
    62 
    63 .wp-watchdog-hero__description {
    64     margin: 10px 0 0;
    65     color: rgba(255, 255, 255, 0.88);
    66     font-size: 15px;
    67     line-height: 1.6;
    68 }
    69 
    70 .wp-watchdog-actions {
    71     display: flex;
    72     align-items: center;
    73     flex-wrap: wrap;
    74     gap: 8px;
    75 }
    76 
    77 .wp-watchdog-hero .button {
    78     min-height: 40px;
    79     border-color: rgba(255, 255, 255, 0.85);
    80 }
    81 
    82 .wp-watchdog-hero .button.button-primary {
    83     color: var(--watchdog-brand-strong);
    84     background: #fff;
    85 }
    86 
    87 .wp-watchdog-hero .button.button-secondary {
    88     color: #fff;
    89     background: rgba(255, 255, 255, 0.1);
    90 }
    91 
    92 .wp-watchdog-nav {
    93     position: sticky;
    94     z-index: 5;
    95     top: 32px;
    96     display: flex;
    97     gap: 4px;
    98     margin: 16px 0;
    99     padding: 6px;
    100     overflow-x: auto;
    101     background: rgba(255, 255, 255, 0.96);
    102     border: 1px solid var(--watchdog-border);
    103     border-radius: 10px;
    104 }
    105 
    106 .wp-watchdog-nav a {
    107     padding: 8px 14px;
    108     color: var(--watchdog-ink);
    109     border-radius: 7px;
    110     font-weight: 600;
    111     text-decoration: none;
    112     white-space: nowrap;
    113 }
    114 
    115 .wp-watchdog-nav a:hover,
    116 .wp-watchdog-nav a:focus-visible {
    117     color: var(--watchdog-brand);
    118     background: #f0f6fc;
    119     outline: none;
    120     box-shadow: 0 0 0 2px var(--watchdog-brand);
    121 }
    122 
    123 .wp-watchdog-overview {
     990    clip: rect(0, 0, 0, 0);
     991    border: 0;
     992  }
     993
     994  .wp-watchdog-risk-table__scroll tbody tr {
    124995    display: grid;
    125     grid-template-columns: repeat(4, minmax(0, 1fr));
    126     gap: 12px;
    127     margin: 16px 0;
    128 }
    129 
    130 .wp-watchdog-stat {
    131     display: grid;
    132     grid-template-columns: auto 1fr;
    133     gap: 2px 12px;
    134     min-height: 96px;
    135     padding: 16px;
    136     color: var(--watchdog-ink);
     996    grid-template-columns: repeat(2, minmax(0, 1fr));
     997    margin-bottom: 12px;
     998    padding: 8px 0;
    137999    background: #fff;
    1381000    border: 1px solid var(--watchdog-border);
    139     border-radius: 10px;
    140     text-decoration: none;
    141     box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
    142     transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease;
    143 }
    144 
    145 .wp-watchdog-stat:hover,
    146 .wp-watchdog-stat:focus-visible {
    147     color: var(--watchdog-ink);
    148     border-color: var(--watchdog-brand);
    149     outline: none;
    150     box-shadow: 0 4px 12px rgba(19, 94, 150, 0.12);
    151     transform: translateY(-1px);
    152 }
    153 
    154 .wp-watchdog-stat__icon {
    155     grid-row: 1 / span 2;
    156     width: 34px;
    157     height: 34px;
    158     padding: 7px;
    159     color: var(--watchdog-brand);
    160     background: #f0f6fc;
    1611001    border-radius: 8px;
    162     font-size: 20px;
    163 }
    164 
    165 .wp-watchdog-stat__value {
    166     align-self: end;
    167     font-size: 26px;
    168     font-weight: 700;
    169     line-height: 1.1;
    170 }
    171 
    172 .wp-watchdog-stat__value--text {
    173     font-size: 18px;
    174 }
    175 
    176 .wp-watchdog-stat__label {
     1002  }
     1003
     1004  .wp-watchdog-risk-table__scroll tbody tr:last-child {
     1005    margin-bottom: 0;
     1006  }
     1007
     1008  .wp-watchdog-risk-table table.widefat td {
     1009    padding: 6px 16px;
     1010    border: 0;
     1011  }
     1012
     1013  .wp-watchdog-risk-table table.widefat td[data-column="plugin"],
     1014  .wp-watchdog-risk-table table.widefat td[data-column="reasons"],
     1015  .wp-watchdog-risk-table table.widefat td[data-column="actions"] {
     1016    grid-column: 1 / -1;
     1017  }
     1018
     1019  .wp-watchdog-risk-table table.widefat td[data-column="plugin"] {
     1020    font-size: 15px;
     1021  }
     1022
     1023  .wp-watchdog-risk-table table.widefat td::before {
     1024    display: block;
     1025    margin-bottom: 2px;
    1771026    color: var(--watchdog-muted);
    178     font-size: 13px;
    179 }
    180 
    181 .wp-watchdog-risk-table {
    182     margin-top: 1.5rem;
    183 }
    184 
    185 .wp-watchdog-notification-toggle {
    186     display: inline-flex;
    187     align-items: center;
    188     gap: 0.5rem;
     1027    font-size: 11px;
    1891028    font-weight: 600;
    190 }
    191 
    192 .wp-watchdog-notification-fields {
    193     margin-top: 0.5rem;
    194 }
    195 
    196 .wp-watchdog-notification-fields--disabled {
    197     opacity: 0.65;
    198 }
    199 
    200 .wp-watchdog-notification-fields--disabled input,
    201 .wp-watchdog-notification-fields--disabled textarea,
    202 .wp-watchdog-notification-fields--disabled select {
    203     background-color: #f6f7f7;
    204     cursor: not-allowed;
    205 }
    206 
    207 .wp-watchdog-frequency-description--testing {
    208     color: #b32d2e;
    209     font-weight: 600;
    210 }
    211 
    212 .wp-watchdog-frequency-options {
    213     margin-top: 0.5rem;
    214     display: grid;
    215     gap: 0.75rem;
    216 }
    217 
    218 .wp-watchdog-frequency-options__row {
    219     padding: 0.75rem;
    220     border: 1px solid #c3c4c7;
    221     border-radius: 4px;
    222     background: #fff;
    223 }
    224 
    225 .wp-watchdog-frequency-label {
    226     display: block;
    227     font-weight: 600;
    228     margin-bottom: 0.35rem;
    229 }
    230 
    231 .wp-watchdog-frequency-weekly {
    232     display: flex;
    233     align-items: center;
    234     gap: 0.5rem;
    235     margin-bottom: 0.35rem;
    236     flex-wrap: wrap;
    237 }
    238 
    239 .wp-watchdog-risk-table__controls {
    240     display: flex;
    241     align-items: center;
    242     justify-content: space-between;
    243     gap: 1rem;
    244     margin-bottom: 0.75rem;
    245     flex-wrap: wrap;
    246 }
    247 
    248 .wp-watchdog-risk-table__filter-form {
    249     display: flex;
    250     align-items: center;
    251     gap: 0.75rem;
    252     flex-wrap: wrap;
    253 }
    254 
    255 .wp-watchdog-risk-table__search input[type="search"] {
    256     min-width: 220px;
    257 }
    258 
    259 .wp-watchdog-risk-table__sort-select {
    260     display: flex;
    261     align-items: center;
    262     gap: 0.5rem;
    263     flex-wrap: wrap;
    264 }
    265 
    266 .wp-watchdog-risk-table__pagination {
    267     display: flex;
    268     align-items: center;
    269     gap: 0.5rem;
    270 }
    271 
    272 .wp-watchdog-risk-table__pagination button.button {
    273     min-width: 2.5rem;
    274     text-align: center;
    275 }
    276 
    277 .wp-watchdog-risk-table__page-status {
    278     font-weight: 600;
    279 }
    280 
    281 .wp-watchdog-risk-table__scroll {
    282     overflow-x: auto;
    283     border: 1px solid #c3c4c7;
    284     border-radius: 4px;
    285     background: #fff;
    286 }
    287 
    288 .wp-watchdog-risk-table__scroll table {
    289     margin: 0;
    290 }
    291 
    292 .wp-watchdog-risk-table table.wp-list-table th {
    293     vertical-align: middle;
    294 }
    295 
    296 .wp-watchdog-risk-table__sort {
    297     background: none;
    298     border: 0;
    299     padding: 0;
    300     font: inherit;
    301     color: inherit;
    302     cursor: pointer;
    303     display: inline-flex;
    304     align-items: center;
    305     gap: 0.25rem;
    306 }
    307 
    308 .wp-watchdog-risk-table__sort:focus {
    309     outline: 2px solid #2271b1;
    310     outline-offset: 2px;
    311     border-radius: 2px;
    312 }
    313 
    314 .wp-watchdog-risk-table__sort-indicator {
    315     width: 0;
    316     height: 0;
    317     border-left: 4px solid transparent;
    318     border-right: 4px solid transparent;
    319     border-top: 6px solid currentColor;
    320     transform: rotate(0deg);
    321     opacity: 0.3;
    322     transition: transform 0.2s ease, opacity 0.2s ease;
    323 }
    324 
    325 .wp-watchdog-risk-table__sort[aria-sort="ascending"] .wp-watchdog-risk-table__sort-indicator {
    326     transform: rotate(180deg);
    327     opacity: 1;
    328 }
    329 
    330 .wp-watchdog-risk-table__sort[aria-sort="descending"] .wp-watchdog-risk-table__sort-indicator {
    331     transform: rotate(0deg);
    332     opacity: 1;
    333 }
    334 
    335 .wp-watchdog-risk-table__sort[aria-sort="none"] .wp-watchdog-risk-table__sort-indicator {
    336     opacity: 0.3;
    337 }
    338 
    339 .wp-watchdog-risk-table table.wp-list-table tbody td ul {
    340     margin: 0;
    341     padding-left: 1.2rem;
    342 }
    343 
    344 .wp-watchdog-risk-table table.wp-list-table tbody td ul ul {
    345     margin-top: 0.5rem;
    346 }
    347 
    348 .wp-watchdog-risk-table table.wp-list-table tbody td ul li {
    349     margin-bottom: 0.25rem;
    350 }
    351 
    352 .wp-watchdog-severity {
    353     display: inline-flex;
    354     align-items: center;
    355     gap: 0.25rem;
    356     padding: 0.125rem 0.5rem;
    357     border-radius: 999px;
    358     font-size: 0.75rem;
    359     font-weight: 600;
     1029    letter-spacing: 0.04em;
    3601030    text-transform: uppercase;
    361     letter-spacing: 0.04em;
    362     margin-right: 0.5rem;
    363 }
    364 
    365 .wp-watchdog-severity--low {
    366     background: #e7f7ed;
    367     color: #1c5f3a;
    368 }
    369 
    370 .wp-watchdog-severity--medium {
    371     background: #fff4d6;
    372     color: #7a5a00;
    373 }
    374 
    375 .wp-watchdog-severity--high {
    376     background: #fde4df;
    377     color: #922424;
    378 }
    379 
    380 .wp-watchdog-severity--severe {
    381     background: #fbe0e6;
    382     color: #80102a;
    383 }
    384 
    385 .wp-watchdog-vulnerability__title,
    386 .wp-watchdog-vulnerability__cve,
    387 .wp-watchdog-vulnerability__fixed {
    388     display: inline-block;
    389     margin-right: 0.25rem;
    390 }
    391 
    392 .wp-watchdog-admin .wp-watchdog-grid {
    393     display: grid;
    394     grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
    395     gap: 16px;
    396     margin: 16px 0;
    397 }
    398 
    399 .wp-watchdog-admin .wp-watchdog-surface {
    400     background: #fff;
    401     border: 1px solid #dcdcde;
    402     border-radius: 10px;
    403     padding: 16px;
    404     box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
    405 }
    406 
    407 .wp-watchdog-admin .wp-watchdog-surface[id] {
    408     scroll-margin-top: 92px;
    409 }
    410 
    411 .wp-watchdog-admin .wp-watchdog-surface--wide {
    412     grid-column: 1 / -1;
    413 }
    414 
    415 .wp-watchdog-admin .wp-watchdog-section-title {
    416     display: flex;
    417     align-items: center;
    418     gap: 8px;
    419     margin-bottom: 12px;
    420     font-size: 16px;
    421     font-weight: 600;
    422 }
    423 
    424 .wp-watchdog-admin .wp-watchdog-badge {
    425     display: inline-flex;
    426     align-items: center;
    427     gap: 6px;
    428     padding: 4px 10px;
    429     border-radius: 999px;
    430     font-weight: 600;
    431     font-size: 12px;
    432     text-transform: uppercase;
    433     letter-spacing: 0.02em;
    434 }
    435 
    436 .wp-watchdog-admin .wp-watchdog-badge--success {
    437     background: #e7f7ed;
    438     color: #1c5f3a;
    439 }
    440 
    441 .wp-watchdog-admin .wp-watchdog-badge--warning {
    442     background: #fff4d6;
    443     color: #7a5a00;
    444 }
    445 
    446 .wp-watchdog-admin .wp-watchdog-badge--muted {
    447     background: #eef1f3;
    448     color: #1d2327;
    449 }
    450 
    451 .wp-watchdog-admin .wp-watchdog-section-header {
    452     display: flex;
    453     justify-content: space-between;
    454     align-items: center;
    455     flex-wrap: wrap;
    456     gap: 12px;
    457 }
    458 
    459 .wp-watchdog-admin .button-hero {
    460     display: flex;
    461     align-items: center;
    462     gap: 8px;
    463 }
    464 
    465 .wp-watchdog-admin .wp-watchdog-inline-list {
    466     display: flex;
    467     gap: 8px;
    468     flex-wrap: wrap;
    469     margin: 8px 0 0;
    470     padding: 0;
    471     list-style: none;
    472 }
    473 
    474 .wp-watchdog-admin .wp-watchdog-inline-list li {
    475     margin: 0;
    476 }
    477 
    478 .wp-watchdog-admin .wp-watchdog-summary {
    479     display: flex;
    480     align-items: center;
    481     gap: 10px;
    482 }
    483 
    484 .wp-watchdog-admin .wp-watchdog-summary__count {
    485     font-size: 32px;
    486     font-weight: 700;
    487 }
    488 
    489 .wp-watchdog-admin .wp-watchdog-summary__label {
    490     color: #4b5563;
    491 }
    492 
    493 .wp-watchdog-admin .wp-watchdog-card-stack {
    494     display: flex;
    495     flex-direction: column;
    496     gap: 12px;
    497 }
    498 
    499 .wp-watchdog-admin .wp-watchdog-muted {
    500     color: #4b5563;
    501     margin: 0;
    502 }
    503 
    504 .wp-watchdog-endpoint {
    505     overflow-wrap: anywhere;
    506 }
    507 
    508 .wp-watchdog-subheading {
    509     margin-bottom: 8px !important;
    510 }
    511 
    512 .wp-watchdog-block-action {
    513     margin: 12px 0;
    514 }
    515 
    516 .wp-watchdog-channel-summary {
    517     margin-top: 12px !important;
    518 }
    519 
    520 .wp-watchdog-inline-form {
    521     display: inline;
    522 }
    523 
    524 .wp-watchdog-settings-form .form-table {
    525     margin-top: 8px;
    526 }
    527 
    528 .wp-watchdog-settings-form .form-table > tbody > tr > th,
    529 .wp-watchdog-settings-form .form-table > tbody > tr > td {
    530     padding-top: 18px;
    531     padding-bottom: 18px;
    532     border-top: 1px solid #f0f0f1;
    533 }
    534 
    535 .wp-watchdog-settings-form .form-table > tbody > tr:first-child > th,
    536 .wp-watchdog-settings-form .form-table > tbody > tr:first-child > td {
    537     border-top: 0;
    538 }
    539 
    540 .wp-watchdog-settings-form input[type="url"],
    541 .wp-watchdog-settings-form input[type="text"],
    542 .wp-watchdog-settings-form input[type="password"] {
    543     width: min(100%, 620px);
    544 }
    545 
    546 .wp-watchdog-settings-form .description {
    547     max-width: 720px;
    548 }
    549 
    550 .wp-watchdog-admin form.is-busy {
    551     pointer-events: none;
    552     opacity: 0.72;
    553 }
    554 
    555 .wp-watchdog-admin form.is-busy button[type="submit"] .dashicons {
    556     animation: watchdog-spin 0.8s linear infinite;
    557 }
    558 
    559 @keyframes watchdog-spin {
    560     to {
    561         transform: rotate(360deg);
    562     }
    563 }
    564 
    565 .wp-watchdog-admin .wp-watchdog-divider {
    566     border-top: 1px solid #dcdcde;
    567     margin: 16px 0;
    568 }
    569 
    570 .wp-watchdog-admin .wp-watchdog-history-table {
    571     margin-top: 12px;
    572 }
    573 
    574 .wp-watchdog-history-grid {
    575     display: grid;
    576     grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    577     gap: 12px;
    578 }
    579 
    580 .wp-watchdog-history-card {
    581     background: #f6f7f7;
    582     border: 1px solid #dcdcde;
    583     border-radius: 8px;
    584     padding: 12px 14px;
    585     box-shadow: 0 1px 1px rgba(0, 0, 0, 0.02);
    586 }
    587 
    588 .wp-watchdog-history-card__header {
    589     display: flex;
    590     justify-content: space-between;
    591     align-items: flex-start;
    592     gap: 8px;
    593     margin-bottom: 8px;
    594     flex-wrap: wrap;
    595 }
    596 
    597 .wp-watchdog-history-card__title {
    598     margin: 0;
    599     font-weight: 600;
    600     font-size: 14px;
    601     display: flex;
    602     align-items: center;
    603     gap: 6px;
    604     flex: 1 1 auto;
    605     min-width: 0;
    606 }
    607 
    608 .wp-watchdog-history-card__meta {
    609     color: #4b5563;
    610     font-size: 12px;
    611     margin: 0;
    612 }
    613 
    614 .wp-watchdog-history-badge {
    615     display: inline-flex;
    616     align-items: center;
    617     gap: 6px;
    618     padding: 4px 8px;
    619     border-radius: 999px;
    620     font-weight: 600;
    621     font-size: 12px;
    622     text-transform: uppercase;
    623     align-self: flex-start;
    624 }
    625 
    626 .wp-watchdog-history-badge--safe {
    627     background: #e7f7ed;
    628     color: #1c5f3a;
    629 }
    630 
    631 .wp-watchdog-history-badge--risk {
    632     background: #fff4d6;
    633     color: #7a5a00;
    634 }
    635 
    636 .wp-watchdog-history-card__downloads {
    637     display: flex;
    638     flex-wrap: wrap;
    639     gap: 8px;
    640     margin-top: 10px;
    641 }
    642 
    643 @media (max-width: 960px) {
    644     .wp-watchdog-overview {
    645         grid-template-columns: repeat(2, minmax(0, 1fr));
    646     }
    647 
    648     .wp-watchdog-risk-table__controls {
    649         flex-direction: column;
    650         align-items: flex-start;
    651     }
    652 }
    653 
    654 @media (max-width: 782px) {
    655     .wp-watchdog-admin {
    656         margin-right: 10px;
    657     }
    658 
    659     .wp-watchdog-admin .wp-watchdog-hero {
    660         align-items: flex-start;
    661         flex-direction: column;
    662         padding: 20px;
    663     }
    664 
    665     .wp-watchdog-hero .wp-watchdog-actions,
    666     .wp-watchdog-hero .wp-watchdog-actions form,
    667     .wp-watchdog-hero .wp-watchdog-actions .button {
    668         width: 100%;
    669     }
    670 
    671     .wp-watchdog-hero .wp-watchdog-actions .button {
    672         justify-content: center;
    673     }
    674 
    675     .wp-watchdog-nav {
    676         top: 46px;
    677     }
    678 
    679     .wp-watchdog-overview {
    680         grid-template-columns: 1fr;
    681     }
    682 
    683     .wp-watchdog-settings-form .form-table th,
    684     .wp-watchdog-settings-form .form-table td {
    685         display: block;
    686         width: 100%;
    687     }
    688 
    689     .wp-watchdog-settings-form .form-table th {
    690         padding-bottom: 6px;
    691     }
    692 
    693     .wp-watchdog-risk-table__scroll {
    694         border: none;
    695     }
    696 
    697     .wp-watchdog-risk-table__scroll table,
    698     .wp-watchdog-risk-table__scroll thead,
    699     .wp-watchdog-risk-table__scroll tbody,
    700     .wp-watchdog-risk-table__scroll th,
    701     .wp-watchdog-risk-table__scroll td,
    702     .wp-watchdog-risk-table__scroll tr {
    703         display: block;
    704     }
    705 
    706     .wp-watchdog-risk-table__scroll thead tr {
    707         position: absolute;
    708         width: 1px;
    709         height: 1px;
    710         padding: 0;
    711         margin: -1px;
    712         overflow: hidden;
    713         clip: rect(0, 0, 0, 0);
    714         border: 0;
    715     }
    716 
    717     .wp-watchdog-risk-table__scroll tbody tr {
    718         border: 1px solid #c3c4c7;
    719         border-radius: 4px;
    720         margin-bottom: 1rem;
    721         padding: 0.75rem 0;
    722         background: #fff;
    723     }
    724 
    725     .wp-watchdog-risk-table__scroll tbody tr:last-child {
    726         margin-bottom: 0;
    727     }
    728 
    729     .wp-watchdog-risk-table__scroll td {
    730         border: none;
    731         position: relative;
    732         padding: 0.5rem 1rem 0.5rem 45%;
    733         min-height: 2.5rem;
    734     }
    735 
    736     .wp-watchdog-risk-table__scroll td::before {
    737         content: attr(data-column-label);
    738         position: absolute;
    739         left: 1rem;
    740         top: 0.5rem;
    741         font-weight: 600;
    742         width: 40%;
    743         padding-right: 0.5rem;
    744         white-space: normal;
    745     }
    746 
    747     .wp-watchdog-risk-table table.wp-list-table tbody td ul {
    748         padding-left: 1rem;
    749     }
    750 }
     1031    content: attr(data-column-label);
     1032  }
     1033
     1034  .wp-watchdog-risk-table table.widefat td[data-column="plugin"]::before,
     1035  .wp-watchdog-risk-table table.widefat td[data-column="actions"]::before {
     1036    content: none;
     1037  }
     1038}
     1039
     1040@media (max-width: 480px) {
     1041  .wp-watchdog-overview {
     1042    grid-template-columns: minmax(0, 1fr);
     1043  }
     1044}
  • site-add-on-watchdog/trunk/assets/js/admin-table.js

    r3690952 r3799692  
    11(function () {
    2     'use strict';
    3 
    4     const i18n = window.siteAddOnWatchdogTable || {};
    5 
    6     /**
    7      * @param {HTMLElement} tableWrapper
    8      */
    9     function initTable(tableWrapper) {
    10         const tbody = tableWrapper.querySelector('tbody');
    11         if (!tbody) {
     2  "use strict";
     3
     4  const i18n = window.siteAddOnWatchdogTable || {};
     5
     6  /**
     7   * @param {HTMLElement} tableWrapper
     8   */
     9  function initTable(tableWrapper) {
     10    const tbody = tableWrapper.querySelector("tbody");
     11    if (!tbody) {
     12      return;
     13    }
     14
     15    const perPage = parseInt(tableWrapper.dataset.perPage || "10", 10);
     16    const baseRows = Array.from(tbody.querySelectorAll("tr"));
     17    let rows = baseRows.slice();
     18    const pagination = tableWrapper.querySelector("[data-pagination]");
     19    const statusEl = tableWrapper.querySelector("[data-page-status]");
     20    const prevBtn = pagination
     21      ? pagination.querySelector('[data-action="prev"]')
     22      : null;
     23    const nextBtn = pagination
     24      ? pagination.querySelector('[data-action="next"]')
     25      : null;
     26    const headers = Array.from(
     27      tableWrapper.querySelectorAll("[data-sort-key]"),
     28    );
     29    const searchInput = tableWrapper.querySelector("[data-risk-search]");
     30    const sortSelect = tableWrapper.querySelector("[data-risk-sort]");
     31    const orderSelect = tableWrapper.querySelector("[data-risk-order]");
     32
     33    const state = {
     34      page: 1,
     35      perPage: Number.isFinite(perPage) && perPage > 0 ? perPage : rows.length,
     36      sortKey: null,
     37      sortOrder: "asc",
     38      filterQuery: searchInput ? searchInput.value.trim() : "",
     39    };
     40
     41    const initialHeader = headers.find((header) =>
     42      header.hasAttribute("data-sort-initial"),
     43    );
     44    if (initialHeader) {
     45      state.sortKey = initialHeader.getAttribute("data-sort-key");
     46      state.sortOrder =
     47        initialHeader.getAttribute("data-sort-default") || "asc";
     48    }
     49
     50    const VERSION_SORT_KEYS = new Set(["sortLocal", "sortRemote"]);
     51    const NUMERIC_SORT_KEYS = new Set(["sortRiskCount", "sortVersionGap"]);
     52    const NUMERIC_TOKEN_PATTERN = /^\d+$/;
     53
     54    if (sortSelect) {
     55      const selectedOption = sortSelect.selectedOptions[0];
     56      if (selectedOption && selectedOption.dataset.sortKey) {
     57        state.sortKey = selectedOption.dataset.sortKey;
     58        state.sortOrder = orderSelect
     59          ? orderSelect.value
     60          : selectedOption.dataset.sortDefault || "asc";
     61      }
     62    }
     63
     64    function getSortValue(row, key, options = {}) {
     65      const { raw = false } = options;
     66
     67      if (NUMERIC_SORT_KEYS.has(key)) {
     68        const datasetKey = key.replace(/-([a-z])/g, (_, char) =>
     69          char.toUpperCase(),
     70        );
     71        const value = row.dataset[datasetKey];
     72        const parsed = value ? parseFloat(value) : 0;
     73        return Number.isFinite(parsed) ? parsed : 0;
     74      }
     75
     76      if (VERSION_SORT_KEYS.has(key)) {
     77        const columnKey = key === "sortLocal" ? "local" : "remote";
     78        const text = (
     79          row.querySelector(`[data-column="${columnKey}"]`)?.textContent || ""
     80        ).trim();
     81        if (!text) {
     82          return raw ? "" : null;
     83        }
     84        if (text.toLowerCase() === "n/a") {
     85          return raw ? "N/A" : null;
     86        }
     87        return raw ? text : text.toLowerCase();
     88      }
     89
     90      const datasetKey = key.replace(/-([a-z])/g, (_, char) =>
     91        char.toUpperCase(),
     92      );
     93      const value = row.dataset[datasetKey];
     94      if (value !== undefined) {
     95        return raw ? value : value.toLowerCase();
     96      }
     97      const fallback = (
     98        row.querySelector(`[data-column="${key}"]`)?.textContent || ""
     99      ).trim();
     100      return raw ? fallback : fallback.toLowerCase();
     101    }
     102
     103    function parseVersionForSort(value) {
     104      if (typeof value !== "string") {
     105        return { tokens: [], isMissing: true };
     106      }
     107
     108      const trimmed = value.trim();
     109      if (!trimmed || trimmed.toLowerCase() === "n/a") {
     110        return { tokens: [], isMissing: true };
     111      }
     112
     113      const tokens = trimmed.match(/[0-9]+|[a-zA-Z]+/g) || [];
     114      return { tokens, isMissing: tokens.length === 0 };
     115    }
     116
     117    function compareVersionTokens(a, b) {
     118      if (a.isMissing && b.isMissing) {
     119        return 0;
     120      }
     121
     122      if (a.isMissing) {
     123        return 1;
     124      }
     125
     126      if (b.isMissing) {
     127        return -1;
     128      }
     129
     130      const maxLength = Math.max(a.tokens.length, b.tokens.length);
     131      for (let index = 0; index < maxLength; index += 1) {
     132        const tokenA = a.tokens[index];
     133        const tokenB = b.tokens[index];
     134
     135        if (tokenA === undefined && tokenB === undefined) {
     136          return 0;
     137        }
     138
     139        if (tokenA === undefined) {
     140          if (tokenB === undefined) {
     141            return 0;
     142          }
     143          const bIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenB);
     144          return bIsNumeric ? -1 : 1;
     145        }
     146
     147        if (tokenB === undefined) {
     148          const aIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenA);
     149          return aIsNumeric ? 1 : -1;
     150        }
     151
     152        const aIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenA);
     153        const bIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenB);
     154
     155        if (aIsNumeric && bIsNumeric) {
     156          const diff = parseInt(tokenA, 10) - parseInt(tokenB, 10);
     157          if (diff !== 0) {
     158            return diff;
     159          }
     160          continue;
     161        }
     162
     163        if (aIsNumeric && !bIsNumeric) {
     164          return 1;
     165        }
     166
     167        if (!aIsNumeric && bIsNumeric) {
     168          return -1;
     169        }
     170
     171        const tokenALower = tokenA.toLowerCase();
     172        const tokenBLower = tokenB.toLowerCase();
     173        if (tokenALower === tokenBLower) {
     174          continue;
     175        }
     176
     177        return tokenALower > tokenBLower ? 1 : -1;
     178      }
     179
     180      return 0;
     181    }
     182
     183    function updateSortIndicators() {
     184      headers.forEach((header) => {
     185        const sortKey = header.getAttribute("data-sort-key");
     186        if (!sortKey) {
     187          return;
     188        }
     189        if (sortKey === state.sortKey) {
     190          header.setAttribute(
     191            "aria-sort",
     192            state.sortOrder === "asc" ? "ascending" : "descending",
     193          );
     194        } else {
     195          header.setAttribute("aria-sort", "none");
     196        }
     197      });
     198    }
     199
     200    function applyPagination() {
     201      const totalPages = Math.max(1, Math.ceil(rows.length / state.perPage));
     202      if (state.page > totalPages) {
     203        state.page = totalPages;
     204      }
     205
     206      const start = (state.page - 1) * state.perPage;
     207      const end = start + state.perPage;
     208
     209      baseRows.forEach((row) => {
     210        row.style.display = "none";
     211      });
     212
     213      if (statusEl) {
     214        if (rows.length === 0) {
     215          statusEl.textContent = i18n.noResults || "No results.";
     216        } else {
     217          const template = i18n.pageStatus || "Page %1$d of %2$d";
     218          statusEl.textContent = template
     219            .replace("%1$d", String(state.page))
     220            .replace("%2$d", String(totalPages));
     221        }
     222      }
     223
     224      if (prevBtn) {
     225        prevBtn.disabled = state.page <= 1 || rows.length === 0;
     226      }
     227
     228      if (nextBtn) {
     229        nextBtn.disabled = state.page >= totalPages || rows.length === 0;
     230      }
     231
     232      rows.forEach((row, index) => {
     233        row.style.display = index >= start && index < end ? "" : "none";
     234      });
     235    }
     236
     237    function applySort() {
     238      if (!state.sortKey) {
     239        return;
     240      }
     241
     242      const isVersionSort = VERSION_SORT_KEYS.has(state.sortKey);
     243
     244      const sortedRows = rows.slice().sort((a, b) => {
     245        let comparison = 0;
     246
     247        if (NUMERIC_SORT_KEYS.has(state.sortKey)) {
     248          const aValue = getSortValue(a, state.sortKey);
     249          const bValue = getSortValue(b, state.sortKey);
     250          comparison = aValue - bValue;
     251        } else if (isVersionSort) {
     252          const aVersion = parseVersionForSort(
     253            getSortValue(a, state.sortKey, { raw: true }),
     254          );
     255          const bVersion = parseVersionForSort(
     256            getSortValue(b, state.sortKey, { raw: true }),
     257          );
     258          comparison = compareVersionTokens(aVersion, bVersion);
     259        } else {
     260          const aValue = getSortValue(a, state.sortKey);
     261          const bValue = getSortValue(b, state.sortKey);
     262
     263          if (aValue !== bValue) {
     264            comparison = aValue > bValue ? 1 : -1;
     265          }
     266        }
     267
     268        if (state.sortOrder === "asc") {
     269          return comparison;
     270        }
     271
     272        return -comparison;
     273      });
     274
     275      rows = sortedRows;
     276
     277      sortedRows.forEach((row) => tbody.appendChild(row));
     278    }
     279
     280    function syncSortControls() {
     281      if (sortSelect) {
     282        const option = sortSelect.querySelector(
     283          `[data-sort-key="${state.sortKey}"]`,
     284        );
     285        if (option) {
     286          sortSelect.value = option.value;
     287        }
     288      }
     289
     290      if (orderSelect) {
     291        orderSelect.value = state.sortOrder;
     292      }
     293    }
     294
     295    function getFilterText(row) {
     296      return (row.dataset.filterText || row.textContent || "").toLowerCase();
     297    }
     298
     299    function applyFilter() {
     300      const query = state.filterQuery.trim().toLowerCase();
     301      if (!query) {
     302        rows = baseRows.slice();
     303      } else {
     304        rows = baseRows.filter((row) => getFilterText(row).includes(query));
     305      }
     306
     307      applySort();
     308      updateSortIndicators();
     309      syncSortControls();
     310      state.page = 1;
     311      applyPagination();
     312    }
     313
     314    headers.forEach((header) => {
     315      header.addEventListener("click", () => {
     316        const sortKey = header.getAttribute("data-sort-key");
     317        if (!sortKey) {
     318          return;
     319        }
     320
     321        if (state.sortKey === sortKey) {
     322          state.sortOrder = state.sortOrder === "asc" ? "desc" : "asc";
     323        } else {
     324          state.sortKey = sortKey;
     325          state.sortOrder = header.getAttribute("data-sort-default") || "asc";
     326        }
     327
     328        applySort();
     329        updateSortIndicators();
     330        syncSortControls();
     331        state.page = 1;
     332        applyPagination();
     333      });
     334
     335      header.setAttribute("aria-sort", "none");
     336    });
     337
     338    if (searchInput) {
     339      searchInput.addEventListener("input", () => {
     340        state.filterQuery = searchInput.value;
     341        applyFilter();
     342      });
     343    }
     344
     345    if (sortSelect) {
     346      sortSelect.addEventListener("change", () => {
     347        const option = sortSelect.selectedOptions[0];
     348        if (!option || !option.dataset.sortKey) {
     349          return;
     350        }
     351        state.sortKey = option.dataset.sortKey;
     352        state.sortOrder = orderSelect
     353          ? orderSelect.value
     354          : option.dataset.sortDefault || "asc";
     355        applySort();
     356        updateSortIndicators();
     357        syncSortControls();
     358        state.page = 1;
     359        applyPagination();
     360      });
     361    }
     362
     363    if (orderSelect) {
     364      orderSelect.addEventListener("change", () => {
     365        state.sortOrder = orderSelect.value;
     366        applySort();
     367        updateSortIndicators();
     368        syncSortControls();
     369        state.page = 1;
     370        applyPagination();
     371      });
     372    }
     373
     374    if (prevBtn) {
     375      prevBtn.addEventListener("click", () => {
     376        if (state.page > 1) {
     377          state.page -= 1;
     378          applyPagination();
     379        }
     380      });
     381    }
     382
     383    if (nextBtn) {
     384      nextBtn.addEventListener("click", () => {
     385        const totalPages = Math.max(1, Math.ceil(rows.length / state.perPage));
     386        if (state.page < totalPages) {
     387          state.page += 1;
     388          applyPagination();
     389        }
     390      });
     391    }
     392
     393    applyFilter();
     394  }
     395
     396  function initNotificationToggles() {
     397    document.querySelectorAll("[data-watchdog-toggle]").forEach((toggle) => {
     398      const container = toggle.closest("[data-watchdog-notification]");
     399      if (!container) {
     400        return;
     401      }
     402
     403      const fieldWrapper = container.querySelector("[data-watchdog-fields]");
     404      if (!fieldWrapper) {
     405        return;
     406      }
     407
     408      // Hidden fields are still submitted, so a disabled channel keeps its saved values.
     409      const syncState = () => {
     410        fieldWrapper.hidden = !toggle.checked;
     411      };
     412
     413      toggle.addEventListener("change", syncState);
     414      syncState();
     415    });
     416  }
     417
     418  function initFrequencyDescription() {
     419    const select = document.getElementById(
     420      "wp-watchdog-notification-frequency",
     421    );
     422    const description = document.querySelector(
     423      "[data-watchdog-frequency-description]",
     424    );
     425    const optionContainer = document.querySelector(
     426      "[data-watchdog-frequency-options]",
     427    );
     428
     429    if (!select || !description) {
     430      return;
     431    }
     432
     433    const defaultMessage =
     434      description.getAttribute("data-default-message") ||
     435      description.textContent ||
     436      "";
     437    const testingMessage =
     438      description.getAttribute("data-testing-message") || defaultMessage;
     439
     440    const updateMessage = () => {
     441      if (select.value === "testing") {
     442        description.textContent = testingMessage;
     443        description.classList.add("wp-watchdog-frequency-description--testing");
     444      } else {
     445        description.textContent = defaultMessage;
     446        description.classList.remove(
     447          "wp-watchdog-frequency-description--testing",
     448        );
     449      }
     450    };
     451
     452    const updateOptionsVisibility = () => {
     453      if (!optionContainer) {
     454        return;
     455      }
     456
     457      const active = select.value;
     458      optionContainer
     459        .querySelectorAll("[data-watchdog-frequency-target]")
     460        .forEach((section) => {
     461          const target = section.getAttribute("data-watchdog-frequency-target");
     462          const visible = target === active;
     463          section.style.display = visible ? "" : "none";
     464
     465          section.querySelectorAll("input, select").forEach((field) => {
     466            field.disabled = !visible;
     467          });
     468        });
     469    };
     470
     471    select.addEventListener("change", () => {
     472      updateMessage();
     473      updateOptionsVisibility();
     474    });
     475
     476    updateMessage();
     477    updateOptionsVisibility();
     478  }
     479
     480  // WordPress exposes no variable for the admin accent color, and it differs
     481  // between versions and color schemes, so take it from a primary button.
     482  function initAccentColor() {
     483    const root = document.querySelector(".wp-watchdog-admin");
     484    const primary = root ? root.querySelector(".button-primary") : null;
     485    if (!primary) {
     486      return;
     487    }
     488
     489    const color = window.getComputedStyle(primary).backgroundColor;
     490    if (color && color !== "transparent" && color !== "rgba(0, 0, 0, 0)") {
     491      root.style.setProperty("--watchdog-accent", color);
     492    }
     493  }
     494
     495  function initTabs() {
     496    const root = document.querySelector(".wp-watchdog-admin");
     497    if (!root) {
     498      return;
     499    }
     500
     501    const tabs = Array.from(root.querySelectorAll("[data-watchdog-tab]"));
     502    const panels = Array.from(root.querySelectorAll("[data-watchdog-panel]"));
     503    if (tabs.length === 0 || panels.length === 0) {
     504      return;
     505    }
     506
     507    const ids = panels.map((panel) => panel.id);
     508    const storageKey = "siteAddOnWatchdogTab";
     509
     510    const readRemembered = () => {
     511      try {
     512        return window.sessionStorage.getItem(storageKey);
     513      } catch (error) {
     514        return null;
     515      }
     516    };
     517
     518    const activate = (id) => {
     519      panels.forEach((panel) => {
     520        panel.hidden = panel.id !== id;
     521      });
     522      tabs.forEach((tab) => {
     523        if (tab.dataset.watchdogTab === id) {
     524          tab.setAttribute("aria-current", "true");
     525        } else {
     526          tab.removeAttribute("aria-current");
     527        }
     528      });
     529
     530      try {
     531        window.sessionStorage.setItem(storageKey, id);
     532      } catch (error) {
     533        // Storage can be unavailable in private windows; the tab still switches.
     534      }
     535    };
     536
     537    // The address bar uses "#tab-risks" for the "watchdog-risks" panel. A hash
     538    // that matched the panel id would make the browser scroll past the header.
     539    const hashFor = (id) => `#${id.replace("watchdog-", "tab-")}`;
     540
     541    const panelFromHash = () => {
     542      const hash = window.location.hash.slice(1);
     543      if (!hash) {
     544        return null;
     545      }
     546
     547      const id = hash.replace(/^tab-/, "watchdog-");
     548      if (ids.includes(id)) {
     549        return id;
     550      }
     551
     552      const target = document.getElementById(hash);
     553      const panel = target ? target.closest("[data-watchdog-panel]") : null;
     554      return panel ? panel.id : null;
     555    };
     556
     557    const requested = root.dataset.watchdogInitialTab || "";
     558    const remembered = readRemembered();
     559    let initial = panelFromHash();
     560    if (!initial && ids.includes(requested)) {
     561      initial = requested;
     562    }
     563    if (!initial && ids.includes(remembered)) {
     564      initial = remembered;
     565    }
     566
     567    root.classList.add("wp-watchdog-admin--js");
     568    activate(initial || ids[0]);
     569
     570    root.addEventListener("click", (event) => {
     571      const link = event.target.closest('a[href^="#"]');
     572      if (!link) {
     573        return;
     574      }
     575
     576      const id = link.getAttribute("href").slice(1);
     577      if (!ids.includes(id)) {
     578        return;
     579      }
     580
     581      event.preventDefault();
     582      activate(id);
     583      if (window.history.replaceState) {
     584        window.history.replaceState(null, "", hashFor(id));
     585      }
     586    });
     587
     588    window.addEventListener("hashchange", () => {
     589      const id = panelFromHash();
     590      if (id) {
     591        activate(id);
     592      }
     593    });
     594  }
     595
     596  function initCopyButtons() {
     597    if (!navigator.clipboard || !navigator.clipboard.writeText) {
     598      return;
     599    }
     600
     601    document.querySelectorAll("[data-watchdog-copy]").forEach((button) => {
     602      const source = document.getElementById(button.dataset.watchdogCopy);
     603      if (!source) {
     604        return;
     605      }
     606
     607      const label = button.textContent;
     608      button.hidden = false;
     609      button.addEventListener("click", () => {
     610        navigator.clipboard
     611          .writeText(source.textContent.trim())
     612          .then(() => {
     613            button.textContent = i18n.copied || "Copied";
     614            window.setTimeout(() => {
     615              button.textContent = label;
     616            }, 2000);
     617          })
     618          .catch(() => {
     619            // Clipboard permission was denied; the text can still be selected by hand.
     620          });
     621      });
     622    });
     623  }
     624
     625  function initFormBusyState() {
     626    // Downloads leave the page in place, so those forms must stay usable.
     627    document
     628      .querySelectorAll(".wp-watchdog-admin form:not([data-watchdog-download])")
     629      .forEach((form) => {
     630        form.addEventListener("submit", (event) => {
     631          if (form.dataset.submitting === "true") {
     632            event.preventDefault();
    12633            return;
    13         }
    14 
    15         const perPage = parseInt(tableWrapper.dataset.perPage || '10', 10);
    16         const baseRows = Array.from(tbody.querySelectorAll('tr'));
    17         let rows = baseRows.slice();
    18         const pagination = tableWrapper.querySelector('[data-pagination]');
    19         const statusEl = tableWrapper.querySelector('[data-page-status]');
    20         const prevBtn = pagination ? pagination.querySelector('[data-action="prev"]') : null;
    21         const nextBtn = pagination ? pagination.querySelector('[data-action="next"]') : null;
    22         const headers = Array.from(tableWrapper.querySelectorAll('[data-sort-key]'));
    23         const searchInput = tableWrapper.querySelector('[data-risk-search]');
    24         const sortSelect = tableWrapper.querySelector('[data-risk-sort]');
    25         const orderSelect = tableWrapper.querySelector('[data-risk-order]');
    26 
    27         const state = {
    28             page: 1,
    29             perPage: Number.isFinite(perPage) && perPage > 0 ? perPage : rows.length,
    30             sortKey: null,
    31             sortOrder: 'asc',
    32             filterQuery: searchInput ? searchInput.value.trim() : ''
    33         };
    34 
    35         const initialHeader = headers.find((header) => header.hasAttribute('data-sort-initial'));
    36         if (initialHeader) {
    37             state.sortKey = initialHeader.getAttribute('data-sort-key');
    38             state.sortOrder = initialHeader.getAttribute('data-sort-default') || 'asc';
    39         }
    40 
    41         const VERSION_SORT_KEYS = new Set(['sortLocal', 'sortRemote']);
    42         const NUMERIC_SORT_KEYS = new Set(['sortRiskCount', 'sortVersionGap']);
    43         const NUMERIC_TOKEN_PATTERN = /^\d+$/;
    44 
    45         if (sortSelect) {
    46             const selectedOption = sortSelect.selectedOptions[0];
    47             if (selectedOption && selectedOption.dataset.sortKey) {
    48                 state.sortKey = selectedOption.dataset.sortKey;
    49                 state.sortOrder = orderSelect
    50                     ? orderSelect.value
    51                     : (selectedOption.dataset.sortDefault || 'asc');
    52             }
    53         }
    54 
    55         function getSortValue(row, key, options = {}) {
    56             const { raw = false } = options;
    57 
    58             if (NUMERIC_SORT_KEYS.has(key)) {
    59                 const datasetKey = key.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
    60                 const value = row.dataset[datasetKey];
    61                 const parsed = value ? parseFloat(value) : 0;
    62                 return Number.isFinite(parsed) ? parsed : 0;
    63             }
    64 
    65             if (VERSION_SORT_KEYS.has(key)) {
    66                 const columnKey = key === 'sortLocal' ? 'local' : 'remote';
    67                 const text = (row.querySelector(`[data-column="${columnKey}"]`)?.textContent || '').trim();
    68                 if (!text) {
    69                     return raw ? '' : null;
    70                 }
    71                 if (text.toLowerCase() === 'n/a') {
    72                     return raw ? 'N/A' : null;
    73                 }
    74                 return raw ? text : text.toLowerCase();
    75             }
    76 
    77             const datasetKey = key.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
    78             const value = row.dataset[datasetKey];
    79             if (value !== undefined) {
    80                 return raw ? value : value.toLowerCase();
    81             }
    82             const fallback = (row.querySelector(`[data-column="${key}"]`)?.textContent || '').trim();
    83             return raw ? fallback : fallback.toLowerCase();
    84         }
    85 
    86         function parseVersionForSort(value) {
    87             if (typeof value !== 'string') {
    88                 return { tokens: [], isMissing: true };
    89             }
    90 
    91             const trimmed = value.trim();
    92             if (!trimmed || trimmed.toLowerCase() === 'n/a') {
    93                 return { tokens: [], isMissing: true };
    94             }
    95 
    96             const tokens = trimmed.match(/[0-9]+|[a-zA-Z]+/g) || [];
    97             return { tokens, isMissing: tokens.length === 0 };
    98         }
    99 
    100         function compareVersionTokens(a, b) {
    101             if (a.isMissing && b.isMissing) {
    102                 return 0;
    103             }
    104 
    105             if (a.isMissing) {
    106                 return 1;
    107             }
    108 
    109             if (b.isMissing) {
    110                 return -1;
    111             }
    112 
    113             const maxLength = Math.max(a.tokens.length, b.tokens.length);
    114             for (let index = 0; index < maxLength; index += 1) {
    115                 const tokenA = a.tokens[index];
    116                 const tokenB = b.tokens[index];
    117 
    118                 if (tokenA === undefined && tokenB === undefined) {
    119                     return 0;
    120                 }
    121 
    122                 if (tokenA === undefined) {
    123                     if (tokenB === undefined) {
    124                         return 0;
    125                     }
    126                     const bIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenB);
    127                     return bIsNumeric ? -1 : 1;
    128                 }
    129 
    130                 if (tokenB === undefined) {
    131                     const aIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenA);
    132                     return aIsNumeric ? 1 : -1;
    133                 }
    134 
    135                 const aIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenA);
    136                 const bIsNumeric = NUMERIC_TOKEN_PATTERN.test(tokenB);
    137 
    138                 if (aIsNumeric && bIsNumeric) {
    139                     const diff = parseInt(tokenA, 10) - parseInt(tokenB, 10);
    140                     if (diff !== 0) {
    141                         return diff;
    142                     }
    143                     continue;
    144                 }
    145 
    146                 if (aIsNumeric && !bIsNumeric) {
    147                     return 1;
    148                 }
    149 
    150                 if (!aIsNumeric && bIsNumeric) {
    151                     return -1;
    152                 }
    153 
    154                 const tokenALower = tokenA.toLowerCase();
    155                 const tokenBLower = tokenB.toLowerCase();
    156                 if (tokenALower === tokenBLower) {
    157                     continue;
    158                 }
    159 
    160                 return tokenALower > tokenBLower ? 1 : -1;
    161             }
    162 
    163             return 0;
    164         }
    165 
    166         function updateSortIndicators() {
    167             headers.forEach((header) => {
    168                 const sortKey = header.getAttribute('data-sort-key');
    169                 if (!sortKey) {
    170                     return;
    171                 }
    172                 if (sortKey === state.sortKey) {
    173                     header.setAttribute('aria-sort', state.sortOrder === 'asc' ? 'ascending' : 'descending');
    174                 } else {
    175                     header.setAttribute('aria-sort', 'none');
    176                 }
    177             });
    178         }
    179 
    180         function applyPagination() {
    181             const totalPages = Math.max(1, Math.ceil(rows.length / state.perPage));
    182             if (state.page > totalPages) {
    183                 state.page = totalPages;
    184             }
    185 
    186             const start = (state.page - 1) * state.perPage;
    187             const end = start + state.perPage;
    188 
    189             baseRows.forEach((row) => {
    190                 row.style.display = 'none';
    191             });
    192 
    193             if (statusEl) {
    194                 if (rows.length === 0) {
    195                     statusEl.textContent = i18n.noResults || 'No results.';
    196                 } else {
    197                     const template = i18n.pageStatus || 'Page %1$d of %2$d';
    198                     statusEl.textContent = template
    199                         .replace('%1$d', String(state.page))
    200                         .replace('%2$d', String(totalPages));
    201                 }
    202             }
    203 
    204             if (prevBtn) {
    205                 prevBtn.disabled = state.page <= 1 || rows.length === 0;
    206             }
    207 
    208             if (nextBtn) {
    209                 nextBtn.disabled = state.page >= totalPages || rows.length === 0;
    210             }
    211 
    212             rows.forEach((row, index) => {
    213                 row.style.display = index >= start && index < end ? '' : 'none';
    214             });
    215         }
    216 
    217         function applySort() {
    218             if (!state.sortKey) {
    219                 return;
    220             }
    221 
    222             const isVersionSort = VERSION_SORT_KEYS.has(state.sortKey);
    223 
    224             const sortedRows = rows.slice().sort((a, b) => {
    225                 let comparison = 0;
    226 
    227                 if (NUMERIC_SORT_KEYS.has(state.sortKey)) {
    228                     const aValue = getSortValue(a, state.sortKey);
    229                     const bValue = getSortValue(b, state.sortKey);
    230                     comparison = aValue - bValue;
    231                 } else if (isVersionSort) {
    232                     const aVersion = parseVersionForSort(getSortValue(a, state.sortKey, { raw: true }));
    233                     const bVersion = parseVersionForSort(getSortValue(b, state.sortKey, { raw: true }));
    234                     comparison = compareVersionTokens(aVersion, bVersion);
    235                 } else {
    236                     const aValue = getSortValue(a, state.sortKey);
    237                     const bValue = getSortValue(b, state.sortKey);
    238 
    239                     if (aValue !== bValue) {
    240                         comparison = aValue > bValue ? 1 : -1;
    241                     }
    242                 }
    243 
    244                 if (state.sortOrder === 'asc') {
    245                     return comparison;
    246                 }
    247 
    248                 return -comparison;
    249             });
    250 
    251             rows = sortedRows;
    252 
    253             sortedRows.forEach((row) => tbody.appendChild(row));
    254         }
    255 
    256         function syncSortControls() {
    257             if (sortSelect) {
    258                 const option = sortSelect.querySelector(`[data-sort-key="${state.sortKey}"]`);
    259                 if (option) {
    260                     sortSelect.value = option.value;
    261                 }
    262             }
    263 
    264             if (orderSelect) {
    265                 orderSelect.value = state.sortOrder;
    266             }
    267         }
    268 
    269         function getFilterText(row) {
    270             return (row.dataset.filterText || row.textContent || '').toLowerCase();
    271         }
    272 
    273         function applyFilter() {
    274             const query = state.filterQuery.trim().toLowerCase();
    275             if (!query) {
    276                 rows = baseRows.slice();
    277             } else {
    278                 rows = baseRows.filter((row) => getFilterText(row).includes(query));
    279             }
    280 
    281             applySort();
    282             updateSortIndicators();
    283             syncSortControls();
    284             state.page = 1;
    285             applyPagination();
    286         }
    287 
    288         headers.forEach((header) => {
    289             header.addEventListener('click', () => {
    290                 const sortKey = header.getAttribute('data-sort-key');
    291                 if (!sortKey) {
    292                     return;
    293                 }
    294 
    295                 if (state.sortKey === sortKey) {
    296                     state.sortOrder = state.sortOrder === 'asc' ? 'desc' : 'asc';
    297                 } else {
    298                     state.sortKey = sortKey;
    299                     state.sortOrder = header.getAttribute('data-sort-default') || 'asc';
    300                 }
    301 
    302                 applySort();
    303                 updateSortIndicators();
    304                 syncSortControls();
    305                 state.page = 1;
    306                 applyPagination();
    307             });
    308 
    309             header.setAttribute('aria-sort', 'none');
     634          }
     635
     636          form.dataset.submitting = "true";
     637          form.classList.add("is-busy");
     638
     639          const submitter = event.submitter;
     640          if (submitter) {
     641            submitter.setAttribute("aria-busy", "true");
     642          }
     643
     644          // Delay disabling buttons so the clicked channel-test value remains in the request.
     645          window.setTimeout(() => {
     646            form
     647              .querySelectorAll('button[type="submit"], input[type="submit"]')
     648              .forEach((button) => {
     649                button.disabled = true;
     650              });
     651          }, 0);
    310652        });
    311 
    312         if (searchInput) {
    313             searchInput.addEventListener('input', () => {
    314                 state.filterQuery = searchInput.value;
    315                 applyFilter();
    316             });
    317         }
    318 
    319         if (sortSelect) {
    320             sortSelect.addEventListener('change', () => {
    321                 const option = sortSelect.selectedOptions[0];
    322                 if (!option || !option.dataset.sortKey) {
    323                     return;
    324                 }
    325                 state.sortKey = option.dataset.sortKey;
    326                 state.sortOrder = orderSelect ? orderSelect.value : (option.dataset.sortDefault || 'asc');
    327                 applySort();
    328                 updateSortIndicators();
    329                 syncSortControls();
    330                 state.page = 1;
    331                 applyPagination();
    332             });
    333         }
    334 
    335         if (orderSelect) {
    336             orderSelect.addEventListener('change', () => {
    337                 state.sortOrder = orderSelect.value;
    338                 applySort();
    339                 updateSortIndicators();
    340                 syncSortControls();
    341                 state.page = 1;
    342                 applyPagination();
    343             });
    344         }
    345 
    346         if (prevBtn) {
    347             prevBtn.addEventListener('click', () => {
    348                 if (state.page > 1) {
    349                     state.page -= 1;
    350                     applyPagination();
    351                 }
    352             });
    353         }
    354 
    355         if (nextBtn) {
    356             nextBtn.addEventListener('click', () => {
    357                 const totalPages = Math.max(1, Math.ceil(rows.length / state.perPage));
    358                 if (state.page < totalPages) {
    359                     state.page += 1;
    360                     applyPagination();
    361                 }
    362             });
    363         }
    364 
    365         applyFilter();
    366     }
    367 
    368     function initNotificationToggles() {
    369         document.querySelectorAll('[data-watchdog-toggle]').forEach((toggle) => {
    370             const container = toggle.closest('[data-watchdog-notification]');
    371             if (!container) {
    372                 return;
    373             }
    374 
    375             const fieldWrapper = container.querySelector('[data-watchdog-fields]');
    376             if (!fieldWrapper) {
    377                 return;
    378             }
    379 
    380             const controlledFields = fieldWrapper.querySelectorAll('input, textarea, select');
    381 
    382             const syncState = () => {
    383                 const disabled = !toggle.checked;
    384                 fieldWrapper.classList.toggle('wp-watchdog-notification-fields--disabled', disabled);
    385 
    386                 controlledFields.forEach((field) => {
    387                     if ('readOnly' in field) {
    388                         field.readOnly = disabled;
    389                     }
    390 
    391                     if (disabled) {
    392                         field.setAttribute('aria-disabled', 'true');
    393                     } else {
    394                         field.removeAttribute('aria-disabled');
    395                     }
    396                 });
    397             };
    398 
    399             toggle.addEventListener('change', syncState);
    400             syncState();
    401         });
    402     }
    403 
    404     function initFrequencyDescription() {
    405         const select = document.getElementById('wp-watchdog-notification-frequency');
    406         const description = document.querySelector('[data-watchdog-frequency-description]');
    407         const optionContainer = document.querySelector('[data-watchdog-frequency-options]');
    408 
    409         if (!select || !description) {
    410             return;
    411         }
    412 
    413         const defaultMessage = description.getAttribute('data-default-message') || description.textContent || '';
    414         const testingMessage = description.getAttribute('data-testing-message') || defaultMessage;
    415 
    416         const updateMessage = () => {
    417             if (select.value === 'testing') {
    418                 description.textContent = testingMessage;
    419                 description.classList.add('wp-watchdog-frequency-description--testing');
    420             } else {
    421                 description.textContent = defaultMessage;
    422                 description.classList.remove('wp-watchdog-frequency-description--testing');
    423             }
    424         };
    425 
    426         const updateOptionsVisibility = () => {
    427             if (!optionContainer) {
    428                 return;
    429             }
    430 
    431             const active = select.value;
    432             optionContainer.querySelectorAll('[data-watchdog-frequency-target]').forEach((section) => {
    433                 const target = section.getAttribute('data-watchdog-frequency-target');
    434                 const visible = target === active;
    435                 section.style.display = visible ? '' : 'none';
    436 
    437                 section.querySelectorAll('input, select').forEach((field) => {
    438                     field.disabled = !visible;
    439                 });
    440             });
    441         };
    442 
    443         select.addEventListener('change', () => {
    444             updateMessage();
    445             updateOptionsVisibility();
    446         });
    447 
    448         updateMessage();
    449         updateOptionsVisibility();
    450     }
    451 
    452     function initFormBusyState() {
    453         document.querySelectorAll('.wp-watchdog-admin form').forEach((form) => {
    454             form.addEventListener('submit', (event) => {
    455                 if (form.dataset.submitting === 'true') {
    456                     event.preventDefault();
    457                     return;
    458                 }
    459 
    460                 form.dataset.submitting = 'true';
    461                 form.classList.add('is-busy');
    462 
    463                 const submitter = event.submitter;
    464                 if (submitter) {
    465                     submitter.setAttribute('aria-busy', 'true');
    466                 }
    467 
    468                 // Delay disabling buttons so the clicked channel-test value remains in the request.
    469                 window.setTimeout(() => {
    470                     form.querySelectorAll('button[type="submit"], input[type="submit"]').forEach((button) => {
    471                         button.disabled = true;
    472                     });
    473                 }, 0);
    474             });
    475         });
    476     }
    477 
    478     window.addEventListener('DOMContentLoaded', () => {
    479         document.querySelectorAll('[data-wp-watchdog-table]').forEach((tableWrapper) => {
    480             initTable(tableWrapper);
    481         });
    482 
    483         initNotificationToggles();
    484         initFrequencyDescription();
    485         initFormBusyState();
    486     });
     653      });
     654  }
     655
     656  window.addEventListener("DOMContentLoaded", () => {
     657    initAccentColor();
     658    initTabs();
     659
     660    document
     661      .querySelectorAll("[data-wp-watchdog-table]")
     662      .forEach((tableWrapper) => {
     663        initTable(tableWrapper);
     664      });
     665
     666    initNotificationToggles();
     667    initFrequencyDescription();
     668    initCopyButtons();
     669    initFormBusyState();
     670  });
    487671})();
  • site-add-on-watchdog/trunk/languages/site-add-on-watchdog.pot

    r3770137 r3799692  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: Site Add-on Watchdog 1.9.0\n"
     5"Project-Id-Version: Site Add-on Watchdog 1.10.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/site-add-on-watchdog\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-10-28T07:35:21+00:00\n"
     12"POT-Creation-Date: 2026-11-13T04:50:43+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    1717#. Plugin Name of the plugin
    1818#: site-add-on-watchdog.php
    19 #: src/AdminPage.php:60
     19#: src/AdminPage.php:82
    2020#: src/Notifications/MessageFormatter.php:293
    2121#: src/Notifications/MessageFormatter.php:376
    22 #: templates/admin-page.php:57
     22#: templates/admin-page.php:171
    2323msgid "Site Add-on Watchdog"
    2424msgstr ""
    … …  
    3939msgstr ""
    4040
    41 #: site-add-on-watchdog.php:21
     41#: site-add-on-watchdog.php:20
    4242msgid "Site Add-on Watchdog requires PHP 8.1 or higher. The plugin has been disabled."
    4343msgstr ""
    4444
    45 #: site-add-on-watchdog.php:74
     45#: site-add-on-watchdog.php:73
    4646msgid "Site Add-on Watchdog could not start. The rest of the site is still available; check the PHP error log and reinstall the plugin files."
    4747msgstr ""
    4848
    49 #: src/AdminPage.php:61
     49#: src/AdminPage.php:83
    5050msgid "Watchdog"
    5151msgstr ""
    5252
    53 #: src/AdminPage.php:72
     53#: src/AdminPage.php:94
    5454msgid "You do not have permission to access this page."
    5555msgstr ""
    5656
    57 #: src/AdminPage.php:216
     57#: src/AdminPage.php:248
    5858msgid "History retention cannot exceed 15 scans. The value has been limited to 15."
    5959msgstr ""
    6060
    61 #: src/AdminPage.php:323
     61#: src/AdminPage.php:353
    6262msgid "No failed notification payload is available."
    6363msgstr ""
    6464
    65 #: src/AdminPage.php:342
     65#: src/AdminPage.php:372
    6666msgid "Invalid history request."
    6767msgstr ""
    6868
    69 #: src/AdminPage.php:353
     69#: src/AdminPage.php:383
    7070msgid "History entry not found."
    7171msgstr ""
    7272
    73 #: src/AdminPage.php:368
     73#: src/AdminPage.php:398
    7474msgid "You do not have permission to perform this action."
    7575msgstr ""
    7676
    7777#. translators: 1: current page number, 2: total number of pages.
    78 #: src/AdminPage.php:408
     78#: src/AdminPage.php:438
    7979#, php-format
    8080msgid "Page %1$d of %2$d"
    8181msgstr ""
    8282
    83 #: src/AdminPage.php:409
     83#: src/AdminPage.php:439
    8484msgid "No risks match your search."
    8585msgstr ""
    8686
    87 #: src/AdminPage.php:493
    88 msgid "Unable to generate history export."
     87#: src/AdminPage.php:440
     88msgid "Copied"
    8989msgstr ""
    9090
    9191#: src/Bootstrap.php:97
    92 #: templates/admin-page.php:86
     92#: templates/admin-page.php:161
    9393msgid "Settings"
    9494msgstr ""
    … …  
    141141#: src/Notifications/MessageFormatter.php:402
    142142#: src/Notifications/MessageFormatter.php:459
    143 #: templates/admin-page.php:568
     143#: templates/admin-page.php:473
    144144msgid "N/A"
    145145msgstr ""
    … …  
    172172#: src/Notifications/MessageFormatter.php:226
    173173#: src/Notifications/MessageFormatter.php:385
     174#: templates/admin-page.php:355
    174175msgid "Review updates"
    175176msgstr ""
    … …  
    223224
    224225#: src/Notifications/MessageFormatter.php:348
    225 #: templates/admin-page.php:409
     226#: templates/admin-page.php:375
    226227msgid "Directory Version"
    227228msgstr ""
    … …  
    242243#: src/Notifications/MessageFormatter.php:401
    243244#: src/Notifications/MessageFormatter.php:458
    244 #: templates/admin-page.php:505
     245#: templates/admin-page.php:384
    245246msgid "Directory version"
    246247msgstr ""
    … …  
    249250#: src/Notifications/MessageFormatter.php:425
    250251#: src/Notifications/MessageFormatter.php:483
    251 #: templates/admin-page.php:641
     252#: templates/admin-page.php:499
    252253#, php-format
    253254msgid "Fixed in %s"
    … …  
    255256
    256257#: src/Notifications/MessageFormatter.php:465
    257 #: templates/admin-page.php:410
    258 #: templates/admin-page.php:513
     258#: templates/admin-page.php:376
     259#: templates/admin-page.php:385
    259260msgid "Reasons"
    260261msgstr ""
    … …  
    281282
    282283#: src/Notifier.php:149
    283 #: templates/admin-page.php:180
     284#: templates/admin-page.php:55
    284285msgid "Custom webhook"
    285286msgstr ""
    … …  
    325326msgstr ""
    326327
    327 #: src/Plugin.php:151
     328#: src/Plugin.php:154
    328329msgid "The scan could not be completed. No saved results were changed; please try again."
    329330msgstr ""
    330331
    331 #: src/Plugin.php:240
     332#: src/Plugin.php:245
    332333msgid "Once Weekly"
    333334msgstr ""
    334335
    335 #: src/Plugin.php:247
    336 #: src/Plugin.php:254
     336#: src/Plugin.php:252
     337#: src/Plugin.php:259
    337338msgid "Every 5 Minutes (Watchdog queue)"
    338339msgstr ""
    339340
    340341#. translators: %d: interval minutes
    341 #: src/Plugin.php:263
     342#: src/Plugin.php:268
    342343#, php-format
    343344msgid "Every %d Minutes (testing)"
    344345msgstr ""
    345346
    346 #: src/Plugin.php:352
    347 msgid "WP-Cron appears disabled. Set a system cron job to trigger wp-cron.php for Site Add-on Watchdog."
    348 msgstr ""
    349 
    350 #: src/Plugin.php:363
     347#: src/Plugin.php:350
     348msgid "The scheduled Site Add-on Watchdog scan is overdue and WP-Cron is disabled on this site. Make sure a system cron job calls wp-cron.php."
     349msgstr ""
     350
     351#: src/Plugin.php:355
    351352msgid "Site Add-on Watchdog scans are overdue. Ensure system cron calls wp-cron.php regularly."
    352353msgstr ""
    353354
    354 #: src/Plugin.php:656
     355#: src/Plugin.php:668
    355356msgid "Notifications processed."
    356357msgstr ""
    357358
    358 #: src/Plugin.php:667
     359#: src/Plugin.php:679
    359360msgid "Scan triggered successfully."
    360361msgstr ""
    361362
    362 #: src/Plugin.php:710
     363#: src/Plugin.php:693
    363364msgid "The cron endpoint only accepts POST requests."
    364365msgstr ""
    365366
     367#: src/Plugin.php:702
     368msgid "Cron secret is missing. Please resave your settings."
     369msgstr ""
     370
     371#: src/Plugin.php:711
     372msgid "Invalid cron secret."
     373msgstr ""
     374
    366375#: src/Plugin.php:719
    367 msgid "Cron secret is missing. Please resave your settings."
    368 msgstr ""
    369 
    370 #: src/Plugin.php:728
    371 msgid "Invalid cron secret."
    372 msgstr ""
    373 
    374 #: src/Plugin.php:736
    375376msgid "Force requests require header authentication."
    376377msgstr ""
    377378
    378 #: src/Scanner.php:89
     379#: src/Scanner.php:90
    379380msgid "An update is available in the plugin directory."
    380381msgstr ""
    381382
    382 #: src/Scanner.php:100
     383#: src/Scanner.php:101
    383384msgid "Local version is more than two minor releases behind the directory version."
    384385msgstr ""
    385386
    386 #: src/Scanner.php:115
     387#: src/Scanner.php:116
    387388msgid "Changelog mentions security-related updates."
    388389msgstr ""
    389390
    390 #: src/Scanner.php:131
     391#: src/Scanner.php:132
    391392msgid "Active vulnerabilities reported by WPScan."
    392393msgstr ""
    393394
    394 #: src/Scanner.php:190
     395#: src/Scanner.php:191
    395396msgid "Severe"
    396397msgstr ""
    397398
    398 #: src/Scanner.php:197
     399#: src/Scanner.php:198
    399400msgid "High"
    400401msgstr ""
    401402
    402 #: src/Scanner.php:204
     403#: src/Scanner.php:205
    403404msgid "Medium"
    404405msgstr ""
    405406
    406 #: src/Scanner.php:210
     407#: src/Scanner.php:211
    407408msgid "Low"
    408409msgstr ""
    … …  
    436437msgstr ""
    437438
    438 #: templates/admin-page.php:38
    439 #: templates/admin-page.php:751
     439#: templates/admin-page.php:51
     440msgid "Email"
     441msgstr ""
     442
     443#: templates/admin-page.php:52
     444msgid "Discord"
     445msgstr ""
     446
     447#: templates/admin-page.php:53
     448msgid "Slack"
     449msgstr ""
     450
     451#: templates/admin-page.php:54
     452msgid "Microsoft Teams"
     453msgstr ""
     454
     455#: templates/admin-page.php:65
     456#: templates/admin-page.php:594
    440457msgid "Daily"
    441458msgstr ""
    442459
    443 #: templates/admin-page.php:39
    444 #: templates/admin-page.php:752
     460#: templates/admin-page.php:66
     461#: templates/admin-page.php:595
    445462msgid "Weekly"
    446463msgstr ""
    447464
    448 #: templates/admin-page.php:40
     465#: templates/admin-page.php:67
    449466msgid "Testing"
    450467msgstr ""
    451468
    452 #: templates/admin-page.php:41
     469#: templates/admin-page.php:68
     470#: templates/admin-page.php:120
    453471msgid "Manual"
    454472msgstr ""
    455473
    456 #: templates/admin-page.php:51
    457 msgid "Plugin health and security"
    458 msgstr ""
    459 
    460 #: templates/admin-page.php:59
    461 msgid "Monitor plugin health, review scan history, and verify every alert channel from one focused dashboard."
    462 msgstr ""
    463 
    464 #: templates/admin-page.php:67
     474#: templates/admin-page.php:77
     475msgid "No scan has run yet. Run a manual scan to create your first report."
     476msgstr ""
     477
     478#. translators: %s: number of plugins.
     479#: templates/admin-page.php:81
     480#, php-format
     481msgid "%s plugin needs attention."
     482msgid_plural "%s plugins need attention."
     483msgstr[0] ""
     484msgstr[1] ""
     485
     486#: templates/admin-page.php:85
     487msgid "No risks found in the latest scan."
     488msgstr ""
     489
     490#: templates/admin-page.php:91
     491msgid "Not scanned yet"
     492msgstr ""
     493
     494#: templates/admin-page.php:94
     495msgid "All clear"
     496msgstr ""
     497
     498#. translators: %s: number of vulnerabilities.
     499#: templates/admin-page.php:99
     500#, php-format
     501msgid "%s known vulnerability"
     502msgid_plural "%s known vulnerabilities"
     503msgstr[0] ""
     504msgstr[1] ""
     505
     506#: templates/admin-page.php:104
     507msgid "Updates available"
     508msgstr ""
     509
     510#: templates/admin-page.php:108
     511msgid "Never"
     512msgstr ""
     513
     514#: templates/admin-page.php:109
     515msgid "Run a manual scan"
     516msgstr ""
     517
     518#. translators: %s: human-readable time difference, for example "2 hours".
     519#: templates/admin-page.php:113
     520#, php-format
     521msgid "%s ago"
     522msgstr ""
     523
     524#: templates/admin-page.php:121
     525msgid "Automatic scans are off"
     526msgstr ""
     527
     528#: templates/admin-page.php:123
     529msgid "Not scheduled"
     530msgstr ""
     531
     532#: templates/admin-page.php:124
     533msgid "Save the settings to reschedule"
     534msgstr ""
     535
     536#. translators: %s: human-readable time difference, for example "2 hours".
     537#: templates/admin-page.php:129
     538#, php-format
     539msgid "In %s"
     540msgstr ""
     541
     542#: templates/admin-page.php:132
     543msgid "Due now"
     544msgstr ""
     545
     546#. translators: 1: scan frequency, 2: date and time.
     547#: templates/admin-page.php:135
     548#, php-format
     549msgid "%1$s, %2$s"
     550msgstr ""
     551
     552#: templates/admin-page.php:144
     553msgid "Last delivery failed"
     554msgstr ""
     555
     556#. translators: %s: number of queued alerts.
     557#: templates/admin-page.php:148
     558#, php-format
     559msgid "%s alert queued"
     560msgid_plural "%s alerts queued"
     561msgstr[0] ""
     562msgstr[1] ""
     563
     564#: templates/admin-page.php:153
     565msgid "No channel enabled"
     566msgstr ""
     567
     568#: templates/admin-page.php:155
     569msgid "No failed deliveries"
     570msgstr ""
     571
     572#: templates/admin-page.php:159
     573msgid "Risks"
     574msgstr ""
     575
     576#: templates/admin-page.php:160
     577msgid "History"
     578msgstr ""
     579
     580#: templates/admin-page.php:162
     581msgid "Delivery"
     582msgstr ""
     583
     584#: templates/admin-page.php:183
     585msgid "Send notifications now"
     586msgstr ""
     587
     588#: templates/admin-page.php:191
    465589msgid "Run manual scan"
    466590msgstr ""
    467591
    468 #: templates/admin-page.php:76
    469 msgid "Send notifications now"
    470 msgstr ""
    471 
    472 #: templates/admin-page.php:82
     592#: templates/admin-page.php:213
     593msgid "Cron secret has not been initialized or could not be saved. Please try saving the settings again."
     594msgstr ""
     595
     596#. translators: 1: WPScan response code, 2: message
     597#: templates/admin-page.php:223
     598#, php-format
     599msgid "WPScan error (%1$s): %2$s"
     600msgstr ""
     601
     602#: templates/admin-page.php:235
     603msgid "Settings saved."
     604msgstr ""
     605
     606#: templates/admin-page.php:239
     607msgid "Manual scan completed."
     608msgstr ""
     609
     610#: templates/admin-page.php:243
     611msgid "Notifications were dispatched."
     612msgstr ""
     613
     614#: templates/admin-page.php:245
     615msgid "Notifications skipped to avoid rapid re-sends. Please wait a moment and try again."
     616msgstr ""
     617
     618#: templates/admin-page.php:249
     619msgid "Queued the captured notification payload for resend."
     620msgstr ""
     621
     622#: templates/admin-page.php:251
     623msgid "No failed notification payload was available to resend."
     624msgstr ""
     625
     626#: templates/admin-page.php:257
     627msgid "Notification channel"
     628msgstr ""
     629
     630#: templates/admin-page.php:263
     631msgid "WordPress accepted the test email for sending. This does not confirm inbox delivery. If it does not arrive, check spam and your site mail or SMTP configuration."
     632msgstr ""
     633
     634#. translators: %s: notification channel name.
     635#: templates/admin-page.php:267
     636#, php-format
     637msgid "%s test notification sent successfully."
     638msgstr ""
     639
     640#. translators: %s: notification channel name.
     641#: templates/admin-page.php:278
     642#, php-format
     643msgid "%s could not be tested until its settings are corrected."
     644msgstr ""
     645
     646#. translators: %s: notification channel name.
     647#: templates/admin-page.php:288
     648#, php-format
     649msgid "%s is not configured yet."
     650msgstr ""
     651
     652#. translators: %s: notification channel name.
     653#: templates/admin-page.php:298
     654#, php-format
     655msgid "%s test delivery failed. Open the Delivery tab for details."
     656msgstr ""
     657
     658#: templates/admin-page.php:306
     659msgid "Watchdog overview"
     660msgstr ""
     661
     662#: templates/admin-page.php:309
     663msgid "Open risks"
     664msgstr ""
     665
     666#: templates/admin-page.php:315
     667msgid "Last scan"
     668msgstr ""
     669
     670#: templates/admin-page.php:321
     671msgid "Next scan"
     672msgstr ""
     673
     674#: templates/admin-page.php:327
     675msgid "Active channels"
     676msgstr ""
     677
     678#: templates/admin-page.php:333
    473679msgid "Watchdog sections"
    474680msgstr ""
    475681
    476 #: templates/admin-page.php:83
    477 msgid "Risks"
    478 msgstr ""
    479 
    480 #: templates/admin-page.php:84
    481 msgid "History"
    482 msgstr ""
    483 
    484 #: templates/admin-page.php:85
    485 msgid "Delivery"
    486 msgstr ""
    487 
    488 #: templates/admin-page.php:89
    489 msgid "Watchdog overview"
    490 msgstr ""
    491 
    492 #: templates/admin-page.php:93
    493 msgid "Open risks"
    494 msgstr ""
    495 
    496 #: templates/admin-page.php:98
    497 msgid "Active channels"
    498 msgstr ""
    499 
    500 #: templates/admin-page.php:103
    501 msgid "Queued alerts"
    502 msgstr ""
    503 
    504 #: templates/admin-page.php:108
    505 msgid "Scan schedule"
    506 msgstr ""
    507 
    508 #: templates/admin-page.php:126
    509 msgid "Cron secret has not been initialized or could not be saved. Please try saving the settings again."
    510 msgstr ""
    511 
    512 #. translators: 1: WPScan response code, 2: message
    513 #: templates/admin-page.php:136
    514 #, php-format
    515 msgid "WPScan error (%1$s): %2$s"
    516 msgstr ""
    517 
    518 #: templates/admin-page.php:148
    519 msgid "Settings saved."
    520 msgstr ""
    521 
    522 #: templates/admin-page.php:152
    523 msgid "Manual scan completed."
    524 msgstr ""
    525 
    526 #: templates/admin-page.php:157
    527 msgid "Notifications were dispatched."
    528 msgstr ""
    529 
    530 #: templates/admin-page.php:159
    531 msgid "Notifications skipped to avoid rapid re-sends. Please wait a moment and try again."
    532 msgstr ""
    533 
    534 #: templates/admin-page.php:161
    535 msgid "No notification changes detected since the last send."
    536 msgstr ""
    537 
    538 #: templates/admin-page.php:167
    539 msgid "Queued the captured notification payload for resend."
    540 msgstr ""
    541 
    542 #: templates/admin-page.php:169
    543 msgid "No failed notification payload was available to resend."
    544 msgstr ""
    545 
    546 #: templates/admin-page.php:176
     682#: templates/admin-page.php:341
     683msgid "Needs attention"
     684msgstr ""
     685
     686#: templates/admin-page.php:351
     687msgid "Potential Risks"
     688msgstr ""
     689
     690#: templates/admin-page.php:352
     691msgid "Plugins with a newer release, a security note in the latest changelog, or a known vulnerability."
     692msgstr ""
     693
     694#: templates/admin-page.php:363
     695msgid "No scan results yet"
     696msgstr ""
     697
    547698#: templates/admin-page.php:364
    548 msgid "Email"
    549 msgstr ""
    550 
    551 #: templates/admin-page.php:177
     699msgid "Run a manual scan to check your plugins now."
     700msgstr ""
     701
     702#: templates/admin-page.php:366
     703msgid "No risks detected."
     704msgstr ""
     705
    552706#: templates/admin-page.php:367
    553 msgid "Discord"
    554 msgstr ""
    555 
    556 #: templates/admin-page.php:178
    557 #: templates/admin-page.php:365
    558 msgid "Slack"
    559 msgstr ""
    560 
    561 #: templates/admin-page.php:179
    562 #: templates/admin-page.php:366
    563 msgid "Microsoft Teams"
    564 msgstr ""
    565 
    566 #: templates/admin-page.php:183
    567 msgid "Notification channel"
    568 msgstr ""
    569 
    570 #: templates/admin-page.php:189
    571 msgid "WordPress accepted the test email for sending. This does not confirm inbox delivery. If it does not arrive, check spam and your site mail or SMTP configuration."
     707msgid "Nothing was flagged in the latest scan."
     708msgstr ""
     709
     710#: templates/admin-page.php:373
     711msgid "Plugin"
     712msgstr ""
     713
     714#: templates/admin-page.php:374
     715msgid "Local Version"
     716msgstr ""
     717
     718#: templates/admin-page.php:377
     719msgid "Actions"
     720msgstr ""
     721
     722#: templates/admin-page.php:380
     723msgid "Plugin name"
     724msgstr ""
     725
     726#: templates/admin-page.php:381
     727msgid "Risk count"
     728msgstr ""
     729
     730#: templates/admin-page.php:382
     731msgid "Version gap"
     732msgstr ""
     733
     734#: templates/admin-page.php:383
     735msgid "Local version"
     736msgstr ""
     737
     738#: templates/admin-page.php:400
     739msgid "Search risks"
     740msgstr ""
     741
     742#: templates/admin-page.php:405
     743msgid "Search plugin or reason"
     744msgstr ""
     745
     746#: templates/admin-page.php:409
     747msgid "Sort risks by"
     748msgstr ""
     749
     750#: templates/admin-page.php:420
     751msgid "Sort order"
     752msgstr ""
     753
     754#: templates/admin-page.php:422
     755msgid "Ascending"
     756msgstr ""
     757
     758#: templates/admin-page.php:423
     759msgid "Descending"
     760msgstr ""
     761
     762#: templates/admin-page.php:425
     763msgid "Apply"
     764msgstr ""
     765
     766#: templates/admin-page.php:428
     767msgid "Previous page"
     768msgstr ""
     769
     770#: templates/admin-page.php:430
     771msgid "Next page"
     772msgstr ""
     773
     774#: templates/admin-page.php:482
     775msgid "WPScan vulnerabilities:"
     776msgstr ""
     777
     778#: templates/admin-page.php:515
     779msgid "Ignore"
     780msgstr ""
     781
     782#: templates/admin-page.php:528
     783msgid "Ignored Plugins"
     784msgstr ""
     785
     786#: templates/admin-page.php:530
     787msgid "No plugins are being ignored."
     788msgstr ""
     789
     790#: templates/admin-page.php:532
     791msgid "Ignored plugins are skipped by every scan. Select one to watch it again."
     792msgstr ""
     793
     794#: templates/admin-page.php:543
     795msgid "Stop ignoring"
     796msgstr ""
     797
     798#: templates/admin-page.php:555
     799msgid "Scan History"
     800msgstr ""
     801
     802#: templates/admin-page.php:566
     803msgid "Scanning"
     804msgstr ""
     805
     806#: templates/admin-page.php:568
     807msgid "Choose how often the automatic scan should run."
     808msgstr ""
     809
     810#. translators: 1: interval minutes, 2: duration hours
     811#: templates/admin-page.php:571
     812#, php-format
     813msgid "Testing mode sends notifications every %1$d minutes to all configured channels and automatically switches back to daily scans after %2$d hours."
     814msgstr ""
     815
     816#: templates/admin-page.php:581
     817msgid "Monday"
     818msgstr ""
     819
     820#: templates/admin-page.php:582
     821msgid "Tuesday"
     822msgstr ""
     823
     824#: templates/admin-page.php:583
     825msgid "Wednesday"
     826msgstr ""
     827
     828#: templates/admin-page.php:584
     829msgid "Thursday"
     830msgstr ""
     831
     832#: templates/admin-page.php:585
     833msgid "Friday"
     834msgstr ""
     835
     836#: templates/admin-page.php:586
     837msgid "Saturday"
     838msgstr ""
     839
     840#: templates/admin-page.php:587
     841msgid "Sunday"
     842msgstr ""
     843
     844#: templates/admin-page.php:591
     845msgid "Scan frequency"
     846msgstr ""
     847
     848#. translators: %d: interval minutes
     849#: templates/admin-page.php:600
     850#, php-format
     851msgid "Testing (every %d minutes)"
     852msgstr ""
     853
     854#: templates/admin-page.php:605
     855msgid "Manual (no automatic scans)"
     856msgstr ""
     857
     858#. translators: 1: datetime, 2: relative time
     859#: templates/admin-page.php:621
     860#, php-format
     861msgid "Testing mode will automatically switch back to daily scans on %1$s (%2$s remaining)."
     862msgstr ""
     863
     864#: templates/admin-page.php:632
     865msgid "Daily send time"
     866msgstr ""
     867
     868#: templates/admin-page.php:641
     869msgid "Time of day to start the daily scan in the WordPress site timezone."
     870msgstr ""
     871
     872#: templates/admin-page.php:645
     873msgid "Weekly send day"
     874msgstr ""
     875
     876#: templates/admin-page.php:653
     877msgid "Weekly send time"
     878msgstr ""
     879
     880#: templates/admin-page.php:662
     881msgid "Day and time to start the weekly scan in the WordPress site timezone."
     882msgstr ""
     883
     884#: templates/admin-page.php:667
     885msgid "History retention"
     886msgstr ""
     887
     888#: templates/admin-page.php:679
     889msgid "Number of recent scans to keep available for review and download (maximum 15)."
     890msgstr ""
     891
     892#: templates/admin-page.php:688
     893msgid "Save and test email"
     894msgstr ""
     895
     896#: templates/admin-page.php:690
     897msgid "Recipients (comma separated)"
     898msgstr ""
     899
     900#: templates/admin-page.php:693
     901msgid "Separate addresses with commas, semicolons, or spaces. WordPress administrator accounts are always included."
     902msgstr ""
     903
     904#: templates/admin-page.php:694
     905msgid "Email uses WordPress mail delivery. Your host must support sending mail, or you must configure an SMTP/mail plugin. Discord and other webhooks do not use this mail configuration."
     906msgstr ""
     907
     908#: templates/admin-page.php:699
     909msgid "Save and test Discord"
     910msgstr ""
     911
     912#: templates/admin-page.php:701
     913msgid "Discord webhook URL"
     914msgstr ""
     915
     916#: templates/admin-page.php:707
     917msgid "Save and test Slack"
     918msgstr ""
     919
     920#: templates/admin-page.php:709
     921msgid "Slack webhook URL"
     922msgstr ""
     923
     924#: templates/admin-page.php:715
     925msgid "Save and test Teams"
     926msgstr ""
     927
     928#: templates/admin-page.php:717
     929msgid "Teams webhook URL"
     930msgstr ""
     931
     932#: templates/admin-page.php:720
     933msgid "Supports Microsoft Teams Workflows and existing Incoming Webhook connectors."
     934msgstr ""
     935
     936#: templates/admin-page.php:725
     937msgid "Save and test webhook"
     938msgstr ""
     939
     940#: templates/admin-page.php:727
     941msgid "Webhook URL"
     942msgstr ""
     943
     944#: templates/admin-page.php:728
     945msgid "Webhook secret (optional)"
     946msgstr ""
     947
     948#: templates/admin-page.php:731
     949msgid "Signs the exact JSON body with HMAC-SHA256 in the X-Watchdog-Signature header. Test messages are marked in the payload."
     950msgstr ""
     951
     952#: templates/admin-page.php:737
     953msgid "Notification channels"
     954msgstr ""
     955
     956#: templates/admin-page.php:738
     957msgid "Save and test buttons save all settings on this form, then send a test to the selected channel."
     958msgstr ""
     959
     960#: templates/admin-page.php:760
     961msgid "On"
     962msgstr ""
     963
     964#: templates/admin-page.php:761
     965msgid "Off"
    572966msgstr ""
    573967
    574968#. translators: %s: notification channel name.
    575 #: templates/admin-page.php:193
    576 #, php-format
    577 msgid "%s test notification sent successfully."
    578 msgstr ""
    579 
    580 #. translators: %s: notification channel name.
    581 #: templates/admin-page.php:204
    582 #, php-format
    583 msgid "%s could not be tested until its settings are corrected."
    584 msgstr ""
    585 
    586 #. translators: %s: notification channel name.
    587 #: templates/admin-page.php:214
    588 #, php-format
    589 msgid "%s is not configured yet."
    590 msgstr ""
    591 
    592 #. translators: %s: notification channel name.
    593 #: templates/admin-page.php:224
    594 #, php-format
    595 msgid "%s test delivery failed. Review Delivery health for details."
    596 msgstr ""
    597 
    598 #: templates/admin-page.php:236
    599 msgid "Delivery health"
    600 msgstr ""
    601 
    602 #: templates/admin-page.php:240
     969#: templates/admin-page.php:768
     970#, php-format
     971msgid "Enable %s notifications"
     972msgstr ""
     973
     974#: templates/admin-page.php:803
     975msgid "Vulnerability data"
     976msgstr ""
     977
     978#: templates/admin-page.php:805
     979msgid "WPScan API key"
     980msgstr ""
     981
     982#: templates/admin-page.php:815
     983msgid "Optional. Provide your own WPScan API key to enrich vulnerability reports."
     984msgstr ""
     985
     986#: templates/admin-page.php:821
     987msgid "Save settings"
     988msgstr ""
     989
     990#: templates/admin-page.php:829
     991msgid "Scheduler"
     992msgstr ""
     993
     994#: templates/admin-page.php:831
     995msgid "WP-Cron disabled"
     996msgstr ""
     997
     998#: templates/admin-page.php:833
     999msgid "WP-Cron enabled"
     1000msgstr ""
     1001
     1002#: templates/admin-page.php:837
    6031003msgid "WP-Cron appears disabled. Use a real cron job or the server endpoint below to keep scans running."
    6041004msgstr ""
    6051005
    606 #: templates/admin-page.php:248
    607 msgid "Retry notifications"
    608 msgstr ""
    609 
    610 #: templates/admin-page.php:252
     1006#: templates/admin-page.php:839
    6111007msgid "WP-Cron is enabled. Scheduled tasks depend on site traffic or a server cron job."
    6121008msgstr ""
    6131009
    614 #: templates/admin-page.php:256
     1010#: templates/admin-page.php:843
     1011msgid "Trigger scans from a server cron job"
     1012msgstr ""
     1013
     1014#: templates/admin-page.php:846
    6151015msgid "Server cron endpoint:"
    6161016msgstr ""
    6171017
    618 #: templates/admin-page.php:261
     1018#: templates/admin-page.php:848
     1019#: templates/admin-page.php:854
     1020#: templates/admin-page.php:859
     1021msgid "Copy"
     1022msgstr ""
     1023
     1024#: templates/admin-page.php:852
    6191025msgid "Generated Cron secret:"
    6201026msgstr ""
    6211027
    622 #: templates/admin-page.php:265
     1028#: templates/admin-page.php:857
    6231029msgid "Authenticated command:"
    6241030msgstr ""
    6251031
    626 #: templates/admin-page.php:268
     1032#: templates/admin-page.php:861
    6271033msgid "Use POST and keep the generated secret private. The header-authenticated request can trigger scans or notification retries even when wp-cron is disabled."
    6281034msgstr ""
    6291035
    630 #: templates/admin-page.php:272
     1036#: templates/admin-page.php:870
    6311037msgid "Queue status"
    6321038msgstr ""
    6331039
    634 #. translators: %d: number of queued jobs.
    635 #: templates/admin-page.php:280
    636 #, php-format
    637 msgid "Queued jobs: %d"
     1040#. translators: %s: number of queued jobs.
     1041#: templates/admin-page.php:875
     1042#, php-format
     1043msgid "Queued jobs: %s"
    6381044msgstr ""
    6391045
    6401046#. translators: 1: scheduled time
    641 #: templates/admin-page.php:290
     1047#: templates/admin-page.php:886
    6421048#, php-format
    6431049msgid "Next attempt: %1$s"
    6441050msgstr ""
    6451051
    646 #: templates/admin-page.php:296
     1052#: templates/admin-page.php:892
    6471053msgid "No pending notifications in the queue."
    6481054msgstr ""
    6491055
    650 #: templates/admin-page.php:301
     1056#: templates/admin-page.php:898
    6511057msgid "Captured notification payloads"
    6521058msgstr ""
    6531059
    654 #: templates/admin-page.php:308
     1060#: templates/admin-page.php:900
     1061msgid "Delivery failed"
     1062msgstr ""
     1063
     1064#: templates/admin-page.php:909
    6551065msgid "Unknown channel"
    6561066msgstr ""
    6571067
    6581068#. translators: 1: human readable time, 2: channel name
    659 #: templates/admin-page.php:315
     1069#: templates/admin-page.php:916
    6601070#, php-format
    6611071msgid "Last failure recorded %1$s via %2$s."
    6621072msgstr ""
    6631073
    664 #: templates/admin-page.php:330
     1074#: templates/admin-page.php:931
    6651075msgid "Re-queue payload"
    6661076msgstr ""
    6671077
    668 #: templates/admin-page.php:338
     1078#: templates/admin-page.php:939
    6691079msgid "Download payload"
    6701080msgstr ""
    6711081
    672 #: templates/admin-page.php:343
     1082#: templates/admin-page.php:944
    6731083msgid "No failed notification payload has been captured yet."
    6741084msgstr ""
    6751085
    676 #: templates/admin-page.php:353
    677 msgid "Scan History"
    678 msgstr ""
    679 
    680 #: templates/admin-page.php:360
    681 msgid "Notification channels"
    682 msgstr ""
    683 
    684 #: templates/admin-page.php:368
    685 msgid "Custom Webhook"
    686 msgstr ""
    687 
    688 #: templates/admin-page.php:380
    689 msgid "On"
    690 msgstr ""
    691 
    692 #: templates/admin-page.php:380
    693 msgid "Off"
    694 msgstr ""
    695 
    696 #: templates/admin-page.php:386
    697 msgid "Keep channels enabled to receive instant alerts and download-ready reports."
    698 msgstr ""
    699 
    700 #: templates/admin-page.php:395
    701 msgid "Potential Risks"
    702 msgstr ""
    703 
    704 #: templates/admin-page.php:399
    705 msgid "items flagged"
    706 msgstr ""
    707 
    708 #: templates/admin-page.php:403
    709 msgid "No risks detected."
    710 msgstr ""
    711 
    712 #: templates/admin-page.php:407
    713 msgid "Plugin"
    714 msgstr ""
    715 
    716 #: templates/admin-page.php:408
    717 msgid "Local Version"
    718 msgstr ""
    719 
    720 #: templates/admin-page.php:411
    721 msgid "Actions"
    722 msgstr ""
    723 
    724 #: templates/admin-page.php:455
    725 msgid "Search risks"
    726 msgstr ""
    727 
    728 #: templates/admin-page.php:460
    729 msgid "Search plugin or reason"
    730 msgstr ""
    731 
    732 #: templates/admin-page.php:465
    733 msgid "Sort risks by"
    734 msgstr ""
    735 
    736 #: templates/admin-page.php:473
    737 msgid "Plugin name"
    738 msgstr ""
    739 
    740 #: templates/admin-page.php:481
    741 msgid "Risk count"
    742 msgstr ""
    743 
    744 #: templates/admin-page.php:489
    745 msgid "Version gap"
    746 msgstr ""
    747 
    748 #: templates/admin-page.php:497
    749 msgid "Local version"
    750 msgstr ""
    751 
    752 #: templates/admin-page.php:516
    753 msgid "Sort order"
    754 msgstr ""
    755 
    756 #: templates/admin-page.php:519
    757 msgid "Ascending"
    758 msgstr ""
    759 
    760 #: templates/admin-page.php:522
    761 msgid "Descending"
    762 msgstr ""
    763 
    764 #: templates/admin-page.php:525
    765 msgid "Apply"
    766 msgstr ""
    767 
    768 #: templates/admin-page.php:529
    769 msgid "Previous page"
    770 msgstr ""
    771 
    772 #: templates/admin-page.php:531
    773 msgid "Next page"
    774 msgstr ""
    775 
    776 #: templates/admin-page.php:623
    777 msgid "WPScan vulnerabilities:"
    778 msgstr ""
    779 
    780 #: templates/admin-page.php:658
    781 msgid "Ignore"
    782 msgstr ""
    783 
    784 #: templates/admin-page.php:674
    785 msgid "Ignored Plugins"
    786 msgstr ""
    787 
    788 #: templates/admin-page.php:677
    789 msgid "No plugins are being ignored."
    790 msgstr ""
    791 
    792 #: templates/admin-page.php:699
    793 msgid "Settings and notifications"
    794 msgstr ""
    795 
    796 #: templates/admin-page.php:701
    797 msgid "Save and test buttons save all settings on this form, then send a test to the selected channel."
    798 msgstr ""
    799 
    800 #: templates/admin-page.php:707
    801 #: templates/admin-page.php:709
    802 msgid "History retention"
    803 msgstr ""
    804 
    805 #: templates/admin-page.php:720
    806 msgid "Number of recent scans to keep available for review and download (maximum 15)."
    807 msgstr ""
    808 
    809 #: templates/admin-page.php:725
    810 #: templates/admin-page.php:749
    811 msgid "Scan frequency"
    812 msgstr ""
    813 
    814 #: templates/admin-page.php:728
    815 msgid "Choose how often the automatic scan should run."
    816 msgstr ""
    817 
    818 #. translators: 1: interval minutes, 2: duration hours
    819 #: templates/admin-page.php:731
    820 #, php-format
    821 msgid "Testing mode sends notifications every %1$d minutes to all configured channels and automatically switches back to daily scans after %2$d hours."
    822 msgstr ""
    823 
    824 #. translators: %d: interval minutes
    825 #: templates/admin-page.php:756
    826 #, php-format
    827 msgid "Testing (every %d minutes)"
    828 msgstr ""
    829 
    830 #: templates/admin-page.php:760
    831 msgid "Manual (no automatic scans)"
    832 msgstr ""
    833 
    834 #: templates/admin-page.php:780
    835 msgid "Daily send time"
    836 msgstr ""
    837 
    838 #: templates/admin-page.php:790
    839 msgid "Time of day to start the daily scan in the WordPress site timezone."
    840 msgstr ""
    841 
    842 #: templates/admin-page.php:796
    843 msgid "Weekly send day"
    844 msgstr ""
    845 
    846 #: templates/admin-page.php:799
    847 msgid "Monday"
    848 msgstr ""
    849 
    850 #: templates/admin-page.php:800
    851 msgid "Tuesday"
    852 msgstr ""
    853 
    854 #: templates/admin-page.php:801
    855 msgid "Wednesday"
    856 msgstr ""
    857 
    858 #: templates/admin-page.php:802
    859 msgid "Thursday"
    860 msgstr ""
    861 
    862 #: templates/admin-page.php:803
    863 msgid "Friday"
    864 msgstr ""
    865 
    866 #: templates/admin-page.php:804
    867 msgid "Saturday"
    868 msgstr ""
    869 
    870 #: templates/admin-page.php:805
    871 msgid "Sunday"
    872 msgstr ""
    873 
    874 #: templates/admin-page.php:810
    875 msgid "Weekly send time"
    876 msgstr ""
    877 
    878 #: templates/admin-page.php:821
    879 msgid "Day and time to start the weekly scan in the WordPress site timezone."
    880 msgstr ""
    881 
    882 #. translators: 1: datetime, 2: relative time
    883 #: templates/admin-page.php:861
    884 #, php-format
    885 msgid "Testing mode will automatically switch back to daily scans on %1$s (%2$s remaining)."
    886 msgstr ""
    887 
    888 #: templates/admin-page.php:877
    889 msgid "Email notifications"
    890 msgstr ""
    891 
    892 #: templates/admin-page.php:881
    893 #: templates/admin-page.php:899
    894 #: templates/admin-page.php:915
    895 #: templates/admin-page.php:931
    896 #: templates/admin-page.php:948
    897 msgid "Enabled"
    898 msgstr ""
    899 
    900 #: templates/admin-page.php:885
    901 msgid "Recipients (comma separated)"
    902 msgstr ""
    903 
    904 #: templates/admin-page.php:888
    905 msgid "Separate addresses with commas, semicolons, or spaces. WordPress administrator accounts are always included."
    906 msgstr ""
    907 
    908 #: templates/admin-page.php:889
    909 msgid "Email uses WordPress mail delivery. Your host must support sending mail, or you must configure an SMTP/mail plugin. Discord and other webhooks do not use this mail configuration."
    910 msgstr ""
    911 
    912 #: templates/admin-page.php:890
    913 msgid "Save and test email"
    914 msgstr ""
    915 
    916 #: templates/admin-page.php:895
    917 msgid "Discord notifications"
    918 msgstr ""
    919 
    920 #: templates/admin-page.php:903
    921 msgid "Discord webhook URL"
    922 msgstr ""
    923 
    924 #: templates/admin-page.php:906
    925 msgid "Save and test Discord"
    926 msgstr ""
    927 
    928 #: templates/admin-page.php:911
    929 msgid "Slack notifications"
    930 msgstr ""
    931 
    932 #: templates/admin-page.php:919
    933 msgid "Slack webhook URL"
    934 msgstr ""
    935 
    936 #: templates/admin-page.php:922
    937 msgid "Save and test Slack"
    938 msgstr ""
    939 
    940 #: templates/admin-page.php:927
    941 msgid "Microsoft Teams notifications"
    942 msgstr ""
    943 
    944 #: templates/admin-page.php:935
    945 msgid "Teams webhook URL"
    946 msgstr ""
    947 
    948 #: templates/admin-page.php:938
    949 msgid "Supports Microsoft Teams Workflows and existing Incoming Webhook connectors."
    950 msgstr ""
    951 
    952 #: templates/admin-page.php:939
    953 msgid "Save and test Teams"
    954 msgstr ""
    955 
    956 #: templates/admin-page.php:944
    957 msgid "Generic webhook"
    958 msgstr ""
    959 
    960 #: templates/admin-page.php:952
    961 msgid "Webhook URL"
    962 msgstr ""
    963 
    964 #: templates/admin-page.php:957
    965 msgid "Webhook secret (optional)"
    966 msgstr ""
    967 
    968 #: templates/admin-page.php:960
    969 msgid "Signs the exact JSON body with HMAC-SHA256 in the X-Watchdog-Signature header. Test messages are marked in the payload."
    970 msgstr ""
    971 
    972 #: templates/admin-page.php:962
    973 msgid "Save and test webhook"
    974 msgstr ""
    975 
    976 #: templates/admin-page.php:967
    977 msgid "WPScan API key"
    978 msgstr ""
    979 
    980 #: templates/admin-page.php:970
    981 msgid "Optional. Provide your own WPScan API key to enrich vulnerability reports."
    982 msgstr ""
    983 
    984 #: templates/admin-page.php:974
    985 msgid "Save settings"
    986 msgstr ""
    987 
    988 #: templates/history.php:21
     1086#: templates/history.php:14
    9891087msgid "No scans have been recorded yet. Run a scan to populate your history."
    9901088msgstr ""
    9911089
    9921090#. translators: 1: number of scans shown, 2: total scans retained
    993 #: templates/history.php:30
     1091#: templates/history.php:20
    9941092#, php-format
    9951093msgid "Showing the last %1$s scans (retaining %2$s in total)."
    … …  
    9971095
    9981096#. translators: %s: number of risks.
    999 #: templates/history.php:55
     1097#: templates/history.php:41
    10001098#, php-format
    10011099msgid "%s risk"
    … …  
    10041102msgstr[1] ""
    10051103
    1006 #: templates/history.php:69
    1007 msgid "Download the report for this run:"
    1008 msgstr ""
    1009 
    1010 #: templates/history.php:73
     1104#: templates/history.php:48
    10111105msgid "Download JSON"
    10121106msgstr ""
    10131107
    1014 #: templates/history.php:76
     1108#: templates/history.php:51
    10151109msgid "Download CSV"
    10161110msgstr ""
  • site-add-on-watchdog/trunk/readme.txt

    r3770137 r3799692  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 1.9.0
     7Stable tag: 1.10.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    1717* A newer release is available for an installed plugin, with an additional warning when it is two or more minor releases behind.
    1818* The latest release's official changelog mentions security or vulnerability fixes.
    19 * (Optional) WPScan reports vulnerabilities affecting the installed version when you provide your own API key.
    20 
    21 The plugin runs on a schedule you control—choose daily, weekly, a twenty-minute testing cadence, or rely on manual scans—and stores results locally. To compare public versions and changelogs, Watchdog looks up plugin slugs individually on WordPress.org and caches successful responses for six hours. WPScan lookups are optional. Email risk alerts to site administrators are enabled by default; webhook channels require setup and activation.
     19* (Optional) WPScan reports vulnerabilities affecting the installed version when you provide your own API key. This also covers plugins that are not listed on WordPress.org.
     20
     21The plugin runs on a schedule you control—choose daily, weekly, a twenty-minute testing cadence, or rely on manual scans—and stores results locally. To compare public versions and changelogs, Watchdog looks up plugin slugs individually on WordPress.org and caches each answer for six hours, including the answer that a plugin is not in the directory. A lookup that fails is retried on the next scan. WPScan lookups are optional. Email risk alerts to site administrators are enabled by default; webhook channels require setup and activation.
    2222
    2323=== Privacy first ===
    … …  
    3838=== Admin tools ===
    3939
    40 * Focused dashboard with risk summaries, searchable history, delivery health, and manual actions.
     40* Tabbed dashboard with a searchable risk table, downloadable scan history, delivery status, and manual actions.
    4141* Ignore list to suppress noisy plugins.
    4242* Validated notification settings with a save-and-test action for every channel.
    … …  
    8383= How do I resend a failed notification payload? =
    8484
    85 Open the Watchdog admin page and check the **Delivery health** section. If a notification fails, the payload is captured there with buttons to re-queue or download it.
     85Open the Watchdog admin page and select the **Delivery** tab. If a notification fails, the payload is captured there with buttons to re-queue or download it.
     86
     87= What happens to my data when I delete the plugin? =
     88
     89Deleting the plugin from the Plugins screen removes its settings, saved risks, ignore list, scan history, notification queue, and scheduled events. Deactivating it only stops the schedule and keeps the data.
    8690
    8791= Where can I find the test suite? =
    … …  
    9397=== Scheduled scans are not running ===
    9498
    95 Watchdog relies on WP-Cron to trigger scheduled scans and notifications. If you have set `DISABLE_WP_CRON` to `true` or your site receives very little traffic (so WP-Cron rarely runs), configure a system cron job to call either `wp-cron.php` or the plugin's REST endpoint. The admin **Delivery health** panel shows the endpoint and generated secret. Send the secret in an HTTP header so it does not appear in access logs; a typical example looks like this:
     99Watchdog relies on WP-Cron to trigger scheduled scans and notifications. If you have set `DISABLE_WP_CRON` to `true` or your site receives very little traffic (so WP-Cron rarely runs), configure a system cron job to call either `wp-cron.php` or the plugin's REST endpoint. The **Delivery** tab on the Watchdog admin page shows the endpoint and generated secret. Send the secret in an HTTP header so it does not appear in access logs; a typical example looks like this:
    96100
    97101`curl -X POST -H "X-Watchdog-Cron-Key: YOUR_GENERATED_SECRET" https://example.com/wp-json/site-add-on-watchdog/v1/cron`
    … …  
    103107Email uses WordPress's `wp_mail()` function and your site's mail configuration. Discord and other webhooks use HTTP, so a working webhook does not confirm that the server can send email.
    104108
    105 Use "Save and test email", then check Delivery health for a mail or SMTP error. Configure your host's mail service or an SMTP/mail plugin if needed. If WordPress accepts the test but no email arrives, check spam and your mail provider's delivery logs, sender verification, and SPF/DKIM settings. Acceptance for sending does not confirm inbox delivery.
     109Use "Save and test email", then check the Delivery tab for a mail or SMTP error. Configure your host's mail service or an SMTP/mail plugin if needed. If WordPress accepts the test but no email arrives, check spam and your mail provider's delivery logs, sender verification, and SPF/DKIM settings. Acceptance for sending does not confirm inbox delivery.
    106110
    107111== CLI Usage ==
    … …  
    125129
    126130== Changelog ==
     131
     132= 1.10.0 =
     133* Split the dashboard into Risks, History, Settings, and Delivery tabs, and show when the last scan ran and when the next one is due.
     134* Check plugins that are not in the WordPress.org directory against WPScan when an API key is configured.
     135* Remove the plugin's saved data when the plugin is deleted.
     136* Turn email alerts on by default, as documented. New installs had them off. Sites that never saved the Watchdog settings will start emailing alerts to administrators.
     137* Show the WP-Cron notice only when the scheduled scan is overdue.
     138* Stop writing to the database on every page load.
     139* Remove an ignored plugin from the risk table at once, and show every table column on narrow screens.
     140* Honor `force` on REST cron scan requests and stop repeating old notices.
    127141
    128142= 1.9.0 =
    … …  
    173187== Upgrade Notice ==
    174188
     189= 1.10.0 =
     190Email alerts to administrators are now on by default as documented. If you never saved the Watchdog settings and do not want them, switch Email off under Settings. Also adds a tabbed dashboard and removes a database write from every page load.
     191
    175192= 1.9.0 =
    176193Adds custom webhook event metadata and clarifies test and scan status messages.
  • site-add-on-watchdog/trunk/site-add-on-watchdog.php

    r3770137 r3799692  
    44 * Plugin Name: Site Add-on Watchdog
    55 * Description: Monitors installed plugins for potential security risks and outdated versions.
    6  * Version:     1.9.0
     6 * Version:     1.10.0
    77 * Author:      Aaron
    88 * Author URI:  https://www.worksbyaaron.com/
    … …  
    1111 * Requires at least: 6.0
    1212 * Requires PHP: 8.1
    13  * Tested up to: 7.1
    1413 */
    1514
  • site-add-on-watchdog/trunk/src/Admin/RiskSorter.php

    r3612684 r3799692  
    2121                'remote' => $this->compareVersions($left->remoteVersion, $right->remoteVersion),
    2222                'reasons' => $this->compareText(
    23                     $this->reasonSortValue($left),
    24                     $this->reasonSortValue($right)
     23                    $this->reasonText($left),
     24                    $this->reasonText($right)
    2525                ),
    26                 'risk_count' => $this->riskSignalCount($left) <=> $this->riskSignalCount($right),
    27                 'version_gap' => $this->versionGapScore($left) <=> $this->versionGapScore($right),
     26                'risk_count' => $this->signalCount($left) <=> $this->signalCount($right),
     27                'version_gap' => $this->versionGap($left) <=> $this->versionGap($right),
    2828                default => 0,
    2929            };
    … …  
    4343    }
    4444
    45     private function normalizeText(string $value): string
     45    public function normalizeText(string $value): string
    4646    {
    4747        $normalized = function_exists('remove_accents') ? remove_accents($value) : $value;
    … …  
    5050    }
    5151
    52     private function reasonSortValue(Risk $risk): string
     52    public function reasonText(Risk $risk): string
    5353    {
    5454        $parts = $risk->reasons;
    … …  
    6464    }
    6565
    66     private function riskSignalCount(Risk $risk): int
     66    public function signalCount(Risk $risk): int
    6767    {
    6868        return count($risk->reasons) + count($this->vulnerabilities($risk));
    … …  
    7272     * @return array<int, array<string, mixed>>
    7373     */
    74     private function vulnerabilities(Risk $risk): array
     74    public function vulnerabilities(Risk $risk): array
    7575    {
    7676        $vulnerabilities = $risk->details['vulnerabilities'] ?? [];
    … …  
    154154    }
    155155
    156     private function versionGapScore(Risk $risk): int
     156    public function versionGap(Risk $risk): int
    157157    {
    158158        return abs(
  • site-add-on-watchdog/trunk/src/AdminPage.php

    r3631212 r3799692  
    99use Watchdog\Repository\SettingsRepository;
    1010use Watchdog\Version;
    11 use WP_Filesystem_Direct;
    1211
    1312class AdminPage
    … …  
    1615    private const MENU_SLUG = 'site-add-on-watchdog';
    1716    private const HISTORY_DOWNLOAD_ACTION = self::PREFIX . '_history_download';
     17    private const NOTICE_ARGS = [
     18        'updated',
     19        'scan',
     20        'notifications',
     21        'failed_notification',
     22        'channel_test',
     23        'channel_test_status',
     24        '_wpnonce',
     25    ];
    1826
    1927    private ?string $menuHook = null;
    2028    private bool $assetsEnqueued = false;
    21     /**
    22      * @var WP_Filesystem_Direct|null
    23      */
    24     private $filesystem = null;
    2529    private readonly RiskSorter $riskSorter;
    2630
    … …  
    5357        );
    5458        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
     59        add_filter('removable_query_args', [$this, 'filterRemovableQueryArgs']);
     60    }
     61
     62    /**
     63     * Lets WordPress drop one-time notice arguments from the address bar so a
     64     * reload does not show the same notice again.
     65     *
     66     * @param string[] $args
     67     * @return string[]
     68     */
     69    public function filterRemovableQueryArgs(array $args): array
     70    {
     71        $page = filter_input(INPUT_GET, 'page', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
     72        if ($page !== self::MENU_SLUG) {
     73            return $args;
     74        }
     75
     76        return array_merge($args, self::NOTICE_ARGS);
    5577    }
    5678
    … …  
    105127        $watchdogRiskSortSelection = $watchdogRiskSort !== '' ? $watchdogRiskSort : 'plugin';
    106128        $watchdogRiskOrderSelection = $watchdogRiskOrder !== '' ? $watchdogRiskOrder : 'asc';
     129        $watchdogRiskRows = $this->buildRiskRows($watchdogRisks);
     130        $watchdogVulnerabilityCount = 0;
     131        foreach ($watchdogRiskRows as $watchdogRiskRow) {
     132            $watchdogVulnerabilityCount += count($watchdogRiskRow['vulnerabilities']);
     133        }
    107134
    108135        $watchdogSettingsError = get_transient(self::PREFIX . '_settings_error');
    … …  
    140167            ];
    141168        }
     169
     170        $watchdogLastScanAt = isset($watchdogHistoryRecords[0]['run_at'])
     171            ? (int) $watchdogHistoryRecords[0]['run_at']
     172            : null;
     173        $watchdogNextScanAt = $this->plugin->getNextScanTimestamp();
    142174
    143175        $watchdogLastFailedNotification = $this->notifier->getLastFailedNotification();
    … …  
    259291        }
    260292
    261         wp_safe_redirect(wp_get_referer() ?: admin_url('admin.php?page=' . self::MENU_SLUG));
     293        wp_safe_redirect($this->returnUrl());
    262294        exit;
    263295    }
    … …  
    273305        }
    274306
    275         wp_safe_redirect(wp_get_referer() ?: admin_url('admin.php?page=' . self::MENU_SLUG));
     307        wp_safe_redirect($this->returnUrl());
    276308        exit;
    277309    }
    … …  
    293325        check_admin_referer(self::PREFIX . '_send_notifications');
    294326
    295         $force           = ! empty($_POST['force']);
    296         $respectThrottle = empty($_POST['ignore_throttle']);
    297         $result          = $this->plugin->sendNotifications($force, $respectThrottle);
     327        $result = $this->plugin->sendNotifications(true);
    298328
    299329        $this->redirectWithNotice(['notifications' => $result]);
    … …  
    408438            'pageStatus' => __('Page %1$d of %2$d', 'site-add-on-watchdog'),
    409439            'noResults' => __('No risks match your search.', 'site-add-on-watchdog'),
     440            'copied' => __('Copied', 'site-add-on-watchdog'),
    410441        ]);
    411442
    … …  
    480511        }
    481512
    482         $target = 'php://output';
    483 
    484         if ($target === 'php://output') {
    485             $csvContent = $this->buildCsvContent($rows);
    486             // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV download response.
    487             echo $csvContent;
    488         } else {
    489             $filesystem = $this->getFilesystem();
    490             $csvContent  = $this->buildCsvContent($rows);
    491 
    492             if (! $filesystem->put_contents($target, $csvContent)) {
    493                 wp_die(esc_html__('Unable to generate history export.', 'site-add-on-watchdog'));
    494             }
    495         }
     513        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV download response.
     514        echo $this->buildCsvContent($rows);
    496515        exit;
    497516    }
    … …  
    534553        $args['_wpnonce'] = wp_create_nonce(self::PREFIX . '_admin_notice');
    535554
    536         wp_safe_redirect(
    537             add_query_arg(
    538                 $args,
    539                 wp_get_referer() ?: admin_url('admin.php?page=' . self::MENU_SLUG)
    540             )
    541         );
     555        wp_safe_redirect(add_query_arg($args, $this->returnUrl()));
     556    }
     557
     558    /**
     559     * The page to return to, without the notice arguments of an earlier action.
     560     * Leaving them in would replay an old notice next to the new one.
     561     */
     562    private function returnUrl(): string
     563    {
     564        $referer = wp_get_referer();
     565        if (! $referer) {
     566            return admin_url('admin.php?page=' . self::MENU_SLUG);
     567        }
     568
     569        return remove_query_arg(self::NOTICE_ARGS, $referer);
    542570    }
    543571
    … …  
    554582    }
    555583
    556     private function getFilesystem()
    557     {
    558         if ($this->filesystem !== null) {
    559             return $this->filesystem;
    560         }
    561 
    562         $this->ensureFilesystemDependenciesLoaded();
    563 
    564         global $wp_filesystem;
    565 
    566         if (! $wp_filesystem instanceof WP_Filesystem_Direct) {
    567             WP_Filesystem();
    568         }
    569 
    570         if ($wp_filesystem instanceof WP_Filesystem_Direct) {
    571             $this->filesystem = $wp_filesystem;
    572 
    573             return $this->filesystem;
    574         }
    575 
    576         $this->filesystem = new WP_Filesystem_Direct(false);
    577 
    578         return $this->filesystem;
    579     }
    580 
    581     private function ensureFilesystemDependenciesLoaded(): void
    582     {
    583         if (! function_exists('request_filesystem_credentials')) {
    584             require_once ABSPATH . 'wp-admin/includes/file.php';
    585         }
    586 
    587         if (! class_exists('WP_Filesystem_Base')) {
    588             require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
    589         }
    590 
    591         if (! class_exists('WP_Filesystem_Direct')) {
    592             require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
    593         }
     584    /**
     585     * Prepares the sort and search attributes the risk table script reads.
     586     *
     587     * @param Risk[] $risks
     588     * @return array<int, array{
     589     *     risk: Risk,
     590     *     vulnerabilities: array<int, array<string, mixed>>,
     591     *     attributes: array<string, string>
     592     * }>
     593     */
     594    private function buildRiskRows(array $risks): array
     595    {
     596        $rows = [];
     597
     598        foreach ($risks as $risk) {
     599            $reasonText = $this->riskSorter->reasonText($risk);
     600
     601            $rows[] = [
     602                'risk'            => $risk,
     603                'vulnerabilities' => $this->riskSorter->vulnerabilities($risk),
     604                'attributes'      => [
     605                    'data-sort-plugin'      => $this->riskSorter->normalizeText($risk->pluginName),
     606                    'data-sort-local'       => $this->riskSorter->normalizeText($risk->localVersion),
     607                    'data-sort-remote'      => $this->riskSorter->normalizeText((string) $risk->remoteVersion),
     608                    'data-sort-reasons'     => $this->riskSorter->normalizeText($reasonText),
     609                    'data-sort-risk-count'  => (string) $this->riskSorter->signalCount($risk),
     610                    'data-sort-version-gap' => (string) $this->riskSorter->versionGap($risk),
     611                    'data-filter-text'      => $this->riskSorter->normalizeText(implode(' ', [
     612                        $risk->pluginName,
     613                        $risk->localVersion,
     614                        (string) $risk->remoteVersion,
     615                        $reasonText,
     616                    ])),
     617                ],
     618            ];
     619        }
     620
     621        return $rows;
    594622    }
    595623}
  • site-add-on-watchdog/trunk/src/Bootstrap.php

    r3612684 r3799692  
    2727        $settingsRepository = new SettingsRepository();
    2828        $riskRepository     = new RiskRepository();
    29         $settings           = $settingsRepository->get();
     29        // Read the key on first use. Loading the settings here would run before
     30        // `plugins_loaded`, which is too early for the user query behind the
     31        // default recipient list.
    3032        $wpscanClient       = new WPScanClient(
    31             (string) ($settings['notifications']['wpscan_api_key'] ?? '')
     33            static fn (): string => (string) ($settingsRepository->get()['notifications']['wpscan_api_key'] ?? '')
    3234        );
    3335        $scanner            = new Scanner(
  • site-add-on-watchdog/trunk/src/Plugin.php

    r3631212 r3799692  
    44
    55use Watchdog\Models\Risk;
     6use Watchdog\Repository\MigratesLegacyOptions;
    67use Watchdog\Repository\RiskRepository;
    78use Watchdog\Repository\SettingsRepository;
    … …  
    1213class Plugin
    1314{
     15    use MigratesLegacyOptions;
     16
    1417    private const PREFIX = Version::PREFIX;
    1518    private const CRON_HOOK = self::PREFIX . '_scheduled_scan';
    … …  
    5558        add_action('rest_api_init', [$this, 'registerRestRoutes']);
    5659
    57         $this->cleanupUpdateCheckState();
    58 
    5960        $this->hooksRegistered = true;
    6061    }
    … …  
    137138     * Executes the scan and persists results.
    138139     *
    139      * @param bool $notify Whether notifications should be dispatched.
     140     * @param bool   $notify  Whether notifications should be dispatched.
     141     * @param string $context 'automatic', 'manual', 'rest', or 'rest-force'. Manual scans are
     142     *                        throttled; forced REST scans notify even when nothing changed.
    140143     *
    141144     * @return bool Whether notifications were dispatched.
    … …  
    176179        $lastHash      = $settings['last_notification'] ?? '';
    177180        $isTestingMode = ($settings['notifications']['frequency'] ?? 'daily') === 'testing';
    178 
    179         $shouldNotify = $notify && ($isTestingMode || (! empty($risks) && $hash !== $lastHash));
     181        $isForced      = $context === 'rest-force';
     182
     183        $shouldNotify = $notify
     184            && ($isTestingMode || $isForced || (! empty($risks) && $hash !== $lastHash));
    180185        $manualThrottle = $context === 'manual'
    181186            && $this->isManualNotificationThrottled($settings, $runAt);
    … …  
    296301    }
    297302
    298     private function cleanupUpdateCheckState(): void
    299     {
    300         delete_option(self::UPDATE_CHECK_OPTION);
    301         delete_option(self::LEGACY_UPDATE_CHECK_OPTION);
    302     }
    303 
    304303    private function scheduleNotificationQueueProcessor(): void
    305304    {
    306         $this->clearScheduledHook(self::LEGACY_QUEUE_CRON_HOOK);
    307 
    308305        $timestamp = wp_next_scheduled(self::QUEUE_CRON_HOOK);
    309306        $currentSchedule = $timestamp ? wp_get_schedule(self::QUEUE_CRON_HOOK) : false;
    … …  
    343340        }
    344341
    345         $status = $this->getOptionWithLegacy(self::CRON_STATUS_OPTION, self::LEGACY_CRON_STATUS_OPTION, []);
    346         if (! is_array($status)) {
    347             return;
    348         }
    349 
    350         if (! empty($status['cron_disabled'])) {
    351             echo '<div class="notice notice-error"><p>'
    352                 . esc_html__(
    353                     'WP-Cron appears disabled. Set a system cron job to trigger wp-cron.php for Site Add-on Watchdog.',
    354                     'site-add-on-watchdog'
    355                 )
    356                 . '</p></div>';
    357 
    358             return;
    359         }
    360 
    361         if (($status['overdue_streak'] ?? 0) >= 2) {
    362             echo '<div class="notice notice-warning"><p>'
    363                 . esc_html__(
    364                     'Site Add-on Watchdog scans are overdue. Ensure system cron calls wp-cron.php regularly.',
    365                     'site-add-on-watchdog'
    366                 )
    367                 . '</p></div>';
    368         }
    369     }
    370 
     342        $status = $this->getCronStatus();
     343        if ($status['overdue_streak'] < 2) {
     344            return;
     345        }
     346
     347        // A disabled WP-Cron is a normal production setup when a system cron
     348        // calls wp-cron.php, so only warn once the scheduled scan is late.
     349        $message = $status['cron_disabled']
     350            ? __(
     351                // phpcs:ignore Generic.Files.LineLength.TooLong -- Translation source must remain one literal string.
     352                'The scheduled Site Add-on Watchdog scan is overdue and WP-Cron is disabled on this site. Make sure a system cron job calls wp-cron.php.',
     353                'site-add-on-watchdog'
     354            )
     355            : __(
     356                'Site Add-on Watchdog scans are overdue. Ensure system cron calls wp-cron.php regularly.',
     357                'site-add-on-watchdog'
     358            );
     359
     360        echo '<div class="notice notice-warning"><p>' . esc_html($message) . '</p></div>';
     361    }
     362
     363    /**
     364     * @return array{cron_disabled: bool, overdue_streak: int}
     365     */
    371366    public function getCronStatus(): array
    372367    {
    373         $status = $this->getOptionWithLegacy(self::CRON_STATUS_OPTION, self::LEGACY_CRON_STATUS_OPTION, []);
    374         if (! is_array($status)) {
    375             $status = [];
    376         }
    377 
    378         return [
    379             'cron_disabled'  => ! empty($status['cron_disabled']),
    380             'overdue_streak' => (int) ($status['overdue_streak'] ?? 0),
    381             'last_checked'   => (int) ($status['last_checked'] ?? 0),
    382         ];
     368        return $this->normalizeCronStatus(
     369            $this->getOptionWithLegacy(self::CRON_STATUS_OPTION, self::LEGACY_CRON_STATUS_OPTION, [])
     370        );
     371    }
     372
     373    public function getNextScanTimestamp(): ?int
     374    {
     375        $timestamp = wp_next_scheduled(self::CRON_HOOK);
     376
     377        return $timestamp ? (int) $timestamp : null;
    383378    }
    384379
    … …  
    439434    }
    440435
     436    /**
     437     * Runs on every request, so it only writes when the status changes.
     438     */
    441439    private function recordCronStatus(bool $cronDisabled, bool $overdue): void
    442440    {
    443         $status = $this->getOptionWithLegacy(self::CRON_STATUS_OPTION, self::LEGACY_CRON_STATUS_OPTION, []);
    444         if (! is_array($status)) {
    445             $status = [
    446                 'overdue_streak' => 0,
    447                 'cron_disabled'  => false,
    448             ];
    449         }
    450 
    451         $status['cron_disabled'] = $cronDisabled;
    452         $status['last_checked']  = time();
    453 
    454         if ($overdue) {
    455             $status['overdue_streak'] = min(10, (int) ($status['overdue_streak'] ?? 0) + 1);
    456         } else {
    457             $status['overdue_streak'] = 0;
    458         }
    459 
    460         update_option(self::CRON_STATUS_OPTION, $status, false);
     441        $stored   = $this->getOptionWithLegacy(self::CRON_STATUS_OPTION, self::LEGACY_CRON_STATUS_OPTION, []);
     442        $previous = $this->normalizeCronStatus($stored);
     443        $status   = [
     444            'cron_disabled'  => $cronDisabled,
     445            'overdue_streak' => $overdue ? min(10, $previous['overdue_streak'] + 1) : 0,
     446        ];
     447
     448        if ($stored === $status) {
     449            return;
     450        }
     451
     452        // Autoloaded because the status is read on every request.
     453        update_option(self::CRON_STATUS_OPTION, $status, true);
     454
     455        if ($status === $previous) {
     456            return;
     457        }
    461458
    462459        if ($cronDisabled) {
    … …  
    467464                . 'Ensure cron can reach wp-cron.php.');
    468465        }
     466    }
     467
     468    /**
     469     * @return array{cron_disabled: bool, overdue_streak: int}
     470     */
     471    private function normalizeCronStatus(mixed $status): array
     472    {
     473        if (! is_array($status)) {
     474            $status = [];
     475        }
     476
     477        return [
     478            'cron_disabled'  => ! empty($status['cron_disabled']),
     479            'overdue_streak' => (int) ($status['overdue_streak'] ?? 0),
     480        ];
    469481    }
    470482
    … …  
    670682    }
    671683
    672     private function getOptionWithLegacy(string $option, string $legacy, mixed $default): mixed
    673     {
    674         $value = get_option($option, $default);
    675         if ($value !== $default) {
    676             return $value;
    677         }
    678 
    679         $legacyValue = get_option($legacy, $default);
    680         if ($legacyValue !== $default) {
    681             update_option($option, $legacyValue, false);
    682 
    683             return $legacyValue;
    684         }
    685 
    686         return $value;
    687     }
    688 
    689     public function validateCronRequest(WP_REST_Request $request): bool
    690     {
    691         [$provided] = $this->cronCredentialFromRequest($request);
    692         $stored     = $this->getCronSecret();
    693 
    694         if ($stored === '') {
    695             return false;
    696         }
    697 
    698         return hash_equals($stored, $provided);
    699     }
    700 
    701684    public function authorizeCronRequest(WP_REST_Request $request): bool|\WP_Error
    702685    {
  • site-add-on-watchdog/trunk/src/Repository/RiskRepository.php

    r3425314 r3799692  
    88class RiskRepository
    99{
     10    use MigratesLegacyOptions;
     11
    1012    private const PREFIX = Version::PREFIX;
    1113    private const RISKS_OPTION = self::PREFIX . '_risks';
    … …  
    2931        }
    3032
     33        // Ignoring a plugin takes effect at once instead of waiting for the next scan.
     34        $ignored    = $this->ignored();
    3135        $normalized = [];
    3236
    … …  
    3741
    3842            if (! isset($entry['plugin_slug'], $entry['plugin_name'], $entry['local_version'])) {
     43                continue;
     44            }
     45
     46            if (in_array((string) $entry['plugin_slug'], $ignored, true)) {
    3947                continue;
    4048            }
    … …  
    241249    }
    242250
    243     private function getOptionWithLegacy(string $option, string $legacyOption, mixed $default): mixed
    244     {
    245         $value = get_option($option, $default);
    246         if ($value !== $default) {
    247             return $value;
    248         }
    249 
    250         $legacyValue = get_option($legacyOption, $default);
    251         if ($legacyValue !== $default) {
    252             update_option($option, $legacyValue, false);
    253 
    254             return $legacyValue;
    255         }
    256 
    257         return $value;
    258     }
    259 
    260251    private function sanitizeRetention(?int $retention): int
    261252    {
  • site-add-on-watchdog/trunk/src/Repository/SettingsRepository.php

    r3612684 r3799692  
    6969        }
    7070
    71         $normalized = $this->normalizeStoredSettings($stored);
     71        $normalized = $this->withoutNulls($this->normalizeStoredSettings($stored));
    7272        $settings   = array_replace_recursive($defaults, $normalized);
    7373
    … …  
    9292
    9393        $recipientList = $settings['notifications']['email']['recipients'] ?? '';
    94         if (! is_string($recipientList) || trim($recipientList) === '') {
    95             $settings['notifications']['email']['recipients'] = $this->buildAdministratorEmailList();
    96         } else {
    97             $settings['notifications']['email']['recipients'] = trim($recipientList);
    98         }
     94        $recipientList = is_string($recipientList) ? trim($recipientList) : '';
     95        if ($recipientList === '' && $shouldPersistDefaults) {
     96            // Prefill once, when the defaults are first stored. A field the site
     97            // owner emptied stays empty: administrators are added at send time,
     98            // and looking them up here would cost a user query on every request.
     99            $recipientList = $this->buildAdministratorEmailList();
     100        }
     101        $settings['notifications']['email']['recipients'] = $recipientList;
    99102
    100103        $settings['history']['retention'] = $this->sanitizeRetention($settings['history']['retention'] ?? null);
    … …  
    396399    }
    397400
     401    /**
     402     * A setting that was never saved is null after normalization. Merging that
     403     * null over the defaults would switch default-on settings off, so drop it.
     404     */
     405    private function withoutNulls(array $values): array
     406    {
     407        foreach ($values as $key => $value) {
     408            if (is_array($value)) {
     409                $values[$key] = $this->withoutNulls($value);
     410            } elseif ($value === null) {
     411                unset($values[$key]);
     412            }
     413        }
     414
     415        return $values;
     416    }
     417
    398418    private function sanitizeFrequency(mixed $frequency): string
    399419    {
  • site-add-on-watchdog/trunk/src/Scanner.php

    r3717433 r3799692  
    1212{
    1313    private const REMOTE_CACHE_HOURS = 6;
     14    private const NOT_IN_DIRECTORY = 'not_in_directory';
    1415
    1516    public function __construct(
    … …  
    229230            return $cached;
    230231        }
     232        if ($cached === self::NOT_IN_DIRECTORY) {
     233            return false;
     234        }
    231235        require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
    232236
    … …  
    240244
    241245        if (is_wp_error($result)) {
     246            if ($this->isMissingFromDirectory($result)) {
     247                // Premium and custom plugins have no directory entry. That is a
     248                // definitive answer, so cache it and keep the WPScan lookup running.
     249                set_transient($cacheKey, self::NOT_IN_DIRECTORY, $this->remoteCacheTtl());
     250
     251                return false;
     252            }
     253
    242254            // A temporary directory/API failure must not erase a saved risk or
    243255            // suppress retries for the full successful-response cache period.
    … …  
    248260
    249261        return $result;
     262    }
     263
     264    /**
     265     * WordPress.org answers an unknown slug with this exact, untranslated API
     266     * message. Any other error is treated as temporary.
     267     */
     268    private function isMissingFromDirectory(object $error): bool
     269    {
     270        return method_exists($error, 'get_error_message')
     271            && trim((string) $error->get_error_message()) === 'Plugin not found.';
    250272    }
    251273
  • site-add-on-watchdog/trunk/src/Services/NotificationQueue.php

    r3425098 r3799692  
    33namespace Watchdog\Services;
    44
     5use Watchdog\Repository\MigratesLegacyOptions;
    56use Watchdog\Version;
    67
    78class NotificationQueue
    89{
     10    use MigratesLegacyOptions;
     11
    912    private const PREFIX = Version::PREFIX;
    1013    private const QUEUE_OPTION  = self::PREFIX . '_notification_queue';
    … …  
    256259    }
    257260
    258     private function getOptionWithLegacy(string $option, string $legacy, mixed $default): mixed
    259     {
    260         $value = get_option($option, $default);
    261         if ($value !== $default) {
    262             return $value;
    263         }
    264 
    265         $legacyValue = get_option($legacy, $default);
    266         if ($legacyValue !== $default) {
    267             update_option($option, $legacyValue, false);
    268 
    269             return $legacyValue;
    270         }
    271 
    272         return $value;
    273     }
    274 
    275261    private function calculateDelay(int $attempts): int
    276262    {
  • site-add-on-watchdog/trunk/src/Services/WPScanClient.php

    r3631212 r3799692  
    1010    private const ERROR_TTL_HOURS = 6;
    1111
    12     public function __construct(private readonly ?string $apiKey)
     12    /**
     13     * @param string|\Closure|null $apiKey The key, or a callback that returns it. The callback
     14     *                                     keeps the settings from being read while plugins load.
     15     */
     16    public function __construct(private string|\Closure|null $apiKey)
    1317    {
    1418    }
    … …  
    1620    public function isEnabled(): bool
    1721    {
    18         return ! empty($this->apiKey);
     22        return $this->apiKey() !== '';
     23    }
     24
     25    private function apiKey(): string
     26    {
     27        if ($this->apiKey instanceof \Closure) {
     28            $this->apiKey = (string) ($this->apiKey)();
     29        }
     30
     31        return (string) $this->apiKey;
    1932    }
    2033
    … …  
    4558            [
    4659                'headers' => [
    47                     'Authorization' => sprintf('Token token=%s', $this->apiKey),
     60                    'Authorization' => sprintf('Token token=%s', $this->apiKey()),
    4861                    'Accept'        => 'application/json',
    4962                ],
  • site-add-on-watchdog/trunk/src/Version.php

    r3770137 r3799692  
    55final class Version
    66{
    7     public const NUMBER = '1.9.0';
     7    public const NUMBER = '1.10.0';
    88    public const PREFIX = 'siteadwa';
    99}
  • site-add-on-watchdog/trunk/templates/admin-page.php

    r3770137 r3799692  
    11<?php
    2 /** @var Risk[] $watchdogRisks */
    3 /** @var string[] $watchdogIgnored */
    4 /** @var array $watchdogSettings */
    5 /** @var int $watchdogHistoryRetention */
    6 /** @var int $watchdogHistoryDisplay */
    7 /** @var array<int, array{run_at:int, risks:array<int, array<string, mixed>>, risk_count:int}> $watchdogHistoryRecords */
    8 /** @var array<int, array<string, string>> $watchdogHistoryDownloads */
    9 /** @var array $watchdogCronStatus */
    10 /** @var string $watchdogCronEndpoint */
    11 /** @var string $watchdogCronSecret */
    12 /** @var string|null $watchdogSettingsError */
    13 /** @var array|null $watchdogWpScanError */
    14 /** @var string|false $watchdogScanError */
    15 /** @var array|null $watchdogLastFailedNotification */
    16 /** @var array{length:int,next_attempt_at:?int} $watchdogQueueStatus */
    17 /** @var string $watchdogActionPrefix */
    18 /** @var array<string, bool> $watchdogNoticeFlags */
    19 /** @var string $watchdogNotificationResult */
    20 /** @var string $watchdogFailedNotificationStatus */
    21 /** @var bool $watchdogCronSecretPersisted */
    22 /** @var string $watchdogChannelTest */
    23 /** @var string $watchdogChannelTestStatus */
     2/**
     3 * Watchdog dashboard.
     4 *
     5 * @var array<int, array{risk: Risk, vulnerabilities: array<int, array<string, mixed>>, attributes: array<string, string>}> $watchdogRiskRows
     6 * @var int $watchdogVulnerabilityCount
     7 * @var string[] $watchdogIgnored
     8 * @var array $watchdogSettings
     9 * @var int $watchdogHistoryRetention
     10 * @var int $watchdogHistoryDisplay
     11 * @var array<int, array{run_at:int, risks:array<int, array<string, mixed>>, risk_count:int}> $watchdogHistoryRecords
     12 * @var array<int, array<string, string>> $watchdogHistoryDownloads
     13 * @var int|null $watchdogLastScanAt
     14 * @var int|null $watchdogNextScanAt
     15 * @var array{cron_disabled: bool, overdue_streak: int} $watchdogCronStatus
     16 * @var string $watchdogCronEndpoint
     17 * @var string $watchdogCronSecret
     18 * @var bool $watchdogCronSecretPersisted
     19 * @var string|false $watchdogSettingsError
     20 * @var array|false $watchdogWpScanError
     21 * @var string|false $watchdogScanError
     22 * @var array|null $watchdogLastFailedNotification
     23 * @var array{length:int,next_attempt_at:?int} $watchdogQueueStatus
     24 * @var string $watchdogActionPrefix
     25 * @var array<string, bool> $watchdogNoticeFlags
     26 * @var string $watchdogNotificationResult
     27 * @var string $watchdogFailedNotificationStatus
     28 * @var string $watchdogChannelTest
     29 * @var string $watchdogChannelTestStatus
     30 * @var string $watchdogRiskSearch
     31 * @var string $watchdogRiskSortSelection
     32 * @var string $watchdogRiskOrderSelection
     33 */
    2434
    2535use Watchdog\Models\Risk;
    2636use Watchdog\TestingMode;
     37
    2738defined('ABSPATH') || exit;
    2839
    29 $watchdogActionPrefix = $watchdogActionPrefix ?? \Watchdog\Version::PREFIX;
     40$watchdogNotifications = $watchdogSettings['notifications'];
     41$watchdogPostUrl       = admin_url('admin-post.php');
     42$watchdogDateFormat    = get_option('date_format') . ' ' . get_option('time_format');
     43$watchdogNow           = time();
     44$watchdogRiskTotal     = count($watchdogRiskRows);
     45$watchdogQueueLength   = (int) ($watchdogQueueStatus['length'] ?? 0);
     46$watchdogQueueNextAttempt = $watchdogQueueStatus['next_attempt_at'] ?? null;
     47$watchdogHasFailedNotification = ! empty($watchdogLastFailedNotification);
     48$watchdogIsCronDisabled = ! empty($watchdogCronStatus['cron_disabled']);
     49
     50$watchdogChannelLabels = [
     51    'email'   => __('Email', 'site-add-on-watchdog'),
     52    'discord' => __('Discord', 'site-add-on-watchdog'),
     53    'slack'   => __('Slack', 'site-add-on-watchdog'),
     54    'teams'   => __('Microsoft Teams', 'site-add-on-watchdog'),
     55    'webhook' => __('Custom webhook', 'site-add-on-watchdog'),
     56];
    3057$watchdogEnabledChannelCount = 0;
    31 foreach (['email', 'discord', 'slack', 'teams', 'webhook'] as $watchdogChannelKey) {
    32     if (! empty($watchdogSettings['notifications'][$watchdogChannelKey]['enabled'])) {
     58foreach (array_keys($watchdogChannelLabels) as $watchdogChannelKey) {
     59    if (! empty($watchdogNotifications[$watchdogChannelKey]['enabled'])) {
    3360        $watchdogEnabledChannelCount++;
    3461    }
    3562}
    36 $watchdogQueueLength = (int) ($watchdogQueueStatus['length'] ?? 0);
     63
    3764$watchdogFrequencyLabels = [
    38     'daily' => __('Daily', 'site-add-on-watchdog'),
    39     'weekly' => __('Weekly', 'site-add-on-watchdog'),
     65    'daily'   => __('Daily', 'site-add-on-watchdog'),
     66    'weekly'  => __('Weekly', 'site-add-on-watchdog'),
    4067    'testing' => __('Testing', 'site-add-on-watchdog'),
    41     'manual' => __('Manual', 'site-add-on-watchdog'),
     68    'manual'  => __('Manual', 'site-add-on-watchdog'),
    4269];
    43 $watchdogCurrentFrequency = (string) ($watchdogSettings['notifications']['frequency'] ?? 'daily');
    44 $watchdogFrequencyLabel = $watchdogFrequencyLabels[$watchdogCurrentFrequency]
    45     ?? $watchdogFrequencyLabels['daily'];
     70$watchdogCurrentFrequency = (string) ($watchdogNotifications['frequency'] ?? 'daily');
     71if (! isset($watchdogFrequencyLabels[$watchdogCurrentFrequency])) {
     72    $watchdogCurrentFrequency = 'daily';
     73}
     74
     75// Header summary.
     76if ($watchdogLastScanAt === null) {
     77    $watchdogSummary = __('No scan has run yet. Run a manual scan to create your first report.', 'site-add-on-watchdog');
     78} elseif ($watchdogRiskTotal > 0) {
     79    $watchdogSummary = sprintf(
     80        /* translators: %s: number of plugins. */
     81        _n('%s plugin needs attention.', '%s plugins need attention.', $watchdogRiskTotal, 'site-add-on-watchdog'),
     82        number_format_i18n($watchdogRiskTotal)
     83    );
     84} else {
     85    $watchdogSummary = __('No risks found in the latest scan.', 'site-add-on-watchdog');
     86}
     87
     88// Overview tiles.
     89if ($watchdogLastScanAt === null) {
     90    $watchdogRiskTone = 'neutral';
     91    $watchdogRiskHint = __('Not scanned yet', 'site-add-on-watchdog');
     92} elseif ($watchdogRiskTotal === 0) {
     93    $watchdogRiskTone = 'ok';
     94    $watchdogRiskHint = __('All clear', 'site-add-on-watchdog');
     95} elseif ($watchdogVulnerabilityCount > 0) {
     96    $watchdogRiskTone = 'danger';
     97    $watchdogRiskHint = sprintf(
     98        /* translators: %s: number of vulnerabilities. */
     99        _n('%s known vulnerability', '%s known vulnerabilities', $watchdogVulnerabilityCount, 'site-add-on-watchdog'),
     100        number_format_i18n($watchdogVulnerabilityCount)
     101    );
     102} else {
     103    $watchdogRiskTone = 'warn';
     104    $watchdogRiskHint = __('Updates available', 'site-add-on-watchdog');
     105}
     106
     107if ($watchdogLastScanAt === null) {
     108    $watchdogLastScanValue = __('Never', 'site-add-on-watchdog');
     109    $watchdogLastScanHint  = __('Run a manual scan', 'site-add-on-watchdog');
     110} else {
     111    $watchdogLastScanValue = sprintf(
     112        /* translators: %s: human-readable time difference, for example "2 hours". */
     113        __('%s ago', 'site-add-on-watchdog'),
     114        human_time_diff($watchdogLastScanAt, $watchdogNow)
     115    );
     116    $watchdogLastScanHint = wp_date($watchdogDateFormat, $watchdogLastScanAt);
     117}
     118
     119if ($watchdogCurrentFrequency === 'manual') {
     120    $watchdogNextScanValue = __('Manual', 'site-add-on-watchdog');
     121    $watchdogNextScanHint  = __('Automatic scans are off', 'site-add-on-watchdog');
     122} elseif ($watchdogNextScanAt === null) {
     123    $watchdogNextScanValue = __('Not scheduled', 'site-add-on-watchdog');
     124    $watchdogNextScanHint  = __('Save the settings to reschedule', 'site-add-on-watchdog');
     125} else {
     126    $watchdogNextScanValue = $watchdogNextScanAt > $watchdogNow
     127        ? sprintf(
     128            /* translators: %s: human-readable time difference, for example "2 hours". */
     129            __('In %s', 'site-add-on-watchdog'),
     130            human_time_diff($watchdogNow, $watchdogNextScanAt)
     131        )
     132        : __('Due now', 'site-add-on-watchdog');
     133    $watchdogNextScanHint = sprintf(
     134        /* translators: 1: scan frequency, 2: date and time. */
     135        __('%1$s, %2$s', 'site-add-on-watchdog'),
     136        $watchdogFrequencyLabels[$watchdogCurrentFrequency],
     137        wp_date($watchdogDateFormat, $watchdogNextScanAt)
     138    );
     139}
     140
     141$watchdogChannelTone = 'neutral';
     142if ($watchdogHasFailedNotification) {
     143    $watchdogChannelTone = 'warn';
     144    $watchdogChannelHint = __('Last delivery failed', 'site-add-on-watchdog');
     145} elseif ($watchdogQueueLength > 0) {
     146    $watchdogChannelHint = sprintf(
     147        /* translators: %s: number of queued alerts. */
     148        _n('%s alert queued', '%s alerts queued', $watchdogQueueLength, 'site-add-on-watchdog'),
     149        number_format_i18n($watchdogQueueLength)
     150    );
     151} elseif ($watchdogEnabledChannelCount === 0) {
     152    $watchdogChannelTone = 'warn';
     153    $watchdogChannelHint = __('No channel enabled', 'site-add-on-watchdog');
     154} else {
     155    $watchdogChannelHint = __('No failed deliveries', 'site-add-on-watchdog');
     156}
     157
     158$watchdogTabs = [
     159    'watchdog-risks'    => __('Risks', 'site-add-on-watchdog'),
     160    'watchdog-history'  => __('History', 'site-add-on-watchdog'),
     161    'watchdog-settings' => __('Settings', 'site-add-on-watchdog'),
     162    'watchdog-delivery' => __('Delivery', 'site-add-on-watchdog'),
     163];
    46164?>
    47 <div class="wrap wp-watchdog-admin">
    48     <header class="wp-watchdog-hero">
    49         <div class="wp-watchdog-hero__content">
    50             <div class="wp-watchdog-hero__eyebrow">
    51                 <span><?php esc_html_e('Plugin health and security', 'site-add-on-watchdog'); ?></span>
    52                 <span aria-hidden="true">&middot;</span>
    53                 <span><?php echo esc_html('v' . \Watchdog\Version::NUMBER); ?></span>
     165<div class="wrap wp-watchdog-admin"<?php echo ! empty($watchdogNoticeFlags['scan']) ? ' data-watchdog-initial-tab="watchdog-risks"' : ''; ?>>
     166    <header class="wp-watchdog-header">
     167        <div class="wp-watchdog-header__brand">
     168            <span class="wp-watchdog-header__icon dashicons dashicons-shield" aria-hidden="true"></span>
     169            <div>
     170                <h1 class="wp-watchdog-header__title">
     171                    <?php esc_html_e('Site Add-on Watchdog', 'site-add-on-watchdog'); ?>
     172                    <span class="wp-watchdog-header__version"><?php echo esc_html('v' . \Watchdog\Version::NUMBER); ?></span>
     173                </h1>
     174                <p class="wp-watchdog-header__summary"><?php echo esc_html($watchdogSummary); ?></p>
    54175            </div>
    55             <h1 class="wp-watchdog-hero__title">
    56                 <span class="dashicons dashicons-shield"></span>
    57                 <?php esc_html_e('Site Add-on Watchdog', 'site-add-on-watchdog'); ?>
    58             </h1>
    59             <p class="wp-watchdog-hero__description"><?php esc_html_e('Monitor plugin health, review scan history, and verify every alert channel from one focused dashboard.', 'site-add-on-watchdog'); ?></p>
    60176        </div>
    61177        <div class="wp-watchdog-actions">
    62             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
     178            <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
     179                <?php wp_nonce_field($watchdogActionPrefix . '_send_notifications'); ?>
     180                <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_send_notifications'); ?>">
     181                <button class="button" type="submit">
     182                    <span class="dashicons dashicons-megaphone" aria-hidden="true"></span>
     183                    <?php esc_html_e('Send notifications now', 'site-add-on-watchdog'); ?>
     184                </button>
     185            </form>
     186            <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
    63187                <?php wp_nonce_field($watchdogActionPrefix . '_scan'); ?>
    64188                <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_scan'); ?>">
    65                 <button class="button button-primary button-hero" type="submit">
     189                <button class="button button-primary" type="submit">
    66190                    <span class="dashicons dashicons-update" aria-hidden="true"></span>
    67191                    <?php esc_html_e('Run manual scan', 'site-add-on-watchdog'); ?>
    68192                </button>
    69193            </form>
    70             <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    71                 <?php wp_nonce_field($watchdogActionPrefix . '_send_notifications'); ?>
    72                 <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_send_notifications'); ?>">
    73                 <input type="hidden" name="force" value="1" />
    74                 <button class="button button-secondary button-hero" type="submit">
    75                     <span class="dashicons dashicons-megaphone" aria-hidden="true"></span>
    76                     <?php esc_html_e('Send notifications now', 'site-add-on-watchdog'); ?>
    77                 </button>
    78             </form>
    79194        </div>
    80195    </header>
    81196
    82     <nav class="wp-watchdog-nav" aria-label="<?php esc_attr_e('Watchdog sections', 'site-add-on-watchdog'); ?>">
    83         <a href="#watchdog-risks"><?php esc_html_e('Risks', 'site-add-on-watchdog'); ?></a>
    84         <a href="#watchdog-history"><?php esc_html_e('History', 'site-add-on-watchdog'); ?></a>
    85         <a href="#watchdog-delivery"><?php esc_html_e('Delivery', 'site-add-on-watchdog'); ?></a>
    86         <a href="#watchdog-settings"><?php esc_html_e('Settings', 'site-add-on-watchdog'); ?></a>
    87     </nav>
    88 
    89     <section class="wp-watchdog-overview" aria-label="<?php esc_attr_e('Watchdog overview', 'site-add-on-watchdog'); ?>">
    90         <a class="wp-watchdog-stat" href="#watchdog-risks">
    91             <span class="wp-watchdog-stat__icon dashicons dashicons-shield-alt" aria-hidden="true"></span>
    92             <span class="wp-watchdog-stat__value"><?php echo esc_html(number_format_i18n(count($watchdogRisks))); ?></span>
    93             <span class="wp-watchdog-stat__label"><?php esc_html_e('Open risks', 'site-add-on-watchdog'); ?></span>
    94         </a>
    95         <a class="wp-watchdog-stat" href="#watchdog-settings">
    96             <span class="wp-watchdog-stat__icon dashicons dashicons-megaphone" aria-hidden="true"></span>
    97             <span class="wp-watchdog-stat__value"><?php echo esc_html(number_format_i18n($watchdogEnabledChannelCount)); ?>/5</span>
    98             <span class="wp-watchdog-stat__label"><?php esc_html_e('Active channels', 'site-add-on-watchdog'); ?></span>
    99         </a>
    100         <a class="wp-watchdog-stat" href="#watchdog-delivery">
    101             <span class="wp-watchdog-stat__icon dashicons dashicons-controls-repeat" aria-hidden="true"></span>
    102             <span class="wp-watchdog-stat__value"><?php echo esc_html(number_format_i18n($watchdogQueueLength)); ?></span>
    103             <span class="wp-watchdog-stat__label"><?php esc_html_e('Queued alerts', 'site-add-on-watchdog'); ?></span>
    104         </a>
    105         <a class="wp-watchdog-stat" href="#watchdog-settings">
    106             <span class="wp-watchdog-stat__icon dashicons dashicons-clock" aria-hidden="true"></span>
    107             <span class="wp-watchdog-stat__value wp-watchdog-stat__value--text"><?php echo esc_html($watchdogFrequencyLabel); ?></span>
    108             <span class="wp-watchdog-stat__label"><?php esc_html_e('Scan schedule', 'site-add-on-watchdog'); ?></span>
    109         </a>
    110     </section>
     197    <hr class="wp-header-end">
    111198
    112199    <?php $watchdogWebhookError = get_transient($watchdogActionPrefix . '_webhook_error'); ?>
    … …  
    153240    <?php endif; ?>
    154241
    155     <?php if ($watchdogNotificationResult !== '') : ?>
    156         <?php if ($watchdogNotificationResult === 'sent') : ?>
    157             <div class="notice notice-success is-dismissible"><p><?php esc_html_e('Notifications were dispatched.', 'site-add-on-watchdog'); ?></p></div>
    158         <?php elseif ($watchdogNotificationResult === 'throttled') : ?>
    159             <div class="notice notice-warning is-dismissible"><p><?php esc_html_e('Notifications skipped to avoid rapid re-sends. Please wait a moment and try again.', 'site-add-on-watchdog'); ?></p></div>
    160         <?php elseif ($watchdogNotificationResult === 'unchanged') : ?>
    161             <div class="notice notice-info is-dismissible"><p><?php esc_html_e('No notification changes detected since the last send.', 'site-add-on-watchdog'); ?></p></div>
    162         <?php endif; ?>
     242    <?php if ($watchdogNotificationResult === 'sent') : ?>
     243        <div class="notice notice-success is-dismissible"><p><?php esc_html_e('Notifications were dispatched.', 'site-add-on-watchdog'); ?></p></div>
     244    <?php elseif ($watchdogNotificationResult === 'throttled') : ?>
     245        <div class="notice notice-warning is-dismissible"><p><?php esc_html_e('Notifications skipped to avoid rapid re-sends. Please wait a moment and try again.', 'site-add-on-watchdog'); ?></p></div>
    163246    <?php endif; ?>
    164247
    165     <?php if ($watchdogFailedNotificationStatus !== '') : ?>
    166         <?php if ($watchdogFailedNotificationStatus === 'resent') : ?>
    167             <div class="notice notice-success is-dismissible"><p><?php esc_html_e('Queued the captured notification payload for resend.', 'site-add-on-watchdog'); ?></p></div>
    168         <?php elseif ($watchdogFailedNotificationStatus === 'missing') : ?>
    169             <div class="notice notice-error is-dismissible"><p><?php esc_html_e('No failed notification payload was available to resend.', 'site-add-on-watchdog'); ?></p></div>
    170         <?php endif; ?>
     248    <?php if ($watchdogFailedNotificationStatus === 'resent') : ?>
     249        <div class="notice notice-success is-dismissible"><p><?php esc_html_e('Queued the captured notification payload for resend.', 'site-add-on-watchdog'); ?></p></div>
     250    <?php elseif ($watchdogFailedNotificationStatus === 'missing') : ?>
     251        <div class="notice notice-error is-dismissible"><p><?php esc_html_e('No failed notification payload was available to resend.', 'site-add-on-watchdog'); ?></p></div>
    171252    <?php endif; ?>
    172253
    173254    <?php if ($watchdogChannelTest !== '' && $watchdogChannelTestStatus !== '') : ?>
    174255        <?php
    175         $watchdogChannelTestLabels = [
    176             'email' => __('Email', 'site-add-on-watchdog'),
    177             'discord' => __('Discord', 'site-add-on-watchdog'),
    178             'slack' => __('Slack', 'site-add-on-watchdog'),
    179             'teams' => __('Microsoft Teams', 'site-add-on-watchdog'),
    180             'webhook' => __('Custom webhook', 'site-add-on-watchdog'),
    181         ];
    182         $watchdogChannelTestLabel = $watchdogChannelTestLabels[$watchdogChannelTest]
     256        $watchdogChannelTestLabel = $watchdogChannelLabels[$watchdogChannelTest]
    183257            ?? __('Notification channel', 'site-add-on-watchdog');
    184258        ?>
    … …  
    222296                printf(
    223297                    /* translators: %s: notification channel name. */
    224                     esc_html__('%s test delivery failed. Review Delivery health for details.', 'site-add-on-watchdog'),
     298                    esc_html__('%s test delivery failed. Open the Delivery tab for details.', 'site-add-on-watchdog'),
    225299                    esc_html($watchdogChannelTestLabel)
    226300                );
    … …  
    230304    <?php endif; ?>
    231305
    232     <div class="wp-watchdog-grid">
    233         <div class="wp-watchdog-surface" id="watchdog-delivery">
    234             <div class="wp-watchdog-section-title">
    235                 <span class="dashicons dashicons-heart" aria-hidden="true"></span>
    236                 <?php esc_html_e('Delivery health', 'site-add-on-watchdog'); ?>
     306    <section class="wp-watchdog-overview" aria-label="<?php esc_attr_e('Watchdog overview', 'site-add-on-watchdog'); ?>">
     307        <a class="wp-watchdog-stat wp-watchdog-stat--<?php echo esc_attr($watchdogRiskTone); ?>" href="#watchdog-risks">
     308            <span class="wp-watchdog-stat__icon dashicons dashicons-shield-alt" aria-hidden="true"></span>
     309            <span class="wp-watchdog-stat__label"><?php esc_html_e('Open risks', 'site-add-on-watchdog'); ?></span>
     310            <span class="wp-watchdog-stat__value"><?php echo esc_html(number_format_i18n($watchdogRiskTotal)); ?></span>
     311            <span class="wp-watchdog-stat__hint"><?php echo esc_html($watchdogRiskHint); ?></span>
     312        </a>
     313        <a class="wp-watchdog-stat" href="#watchdog-history">
     314            <span class="wp-watchdog-stat__icon dashicons dashicons-backup" aria-hidden="true"></span>
     315            <span class="wp-watchdog-stat__label"><?php esc_html_e('Last scan', 'site-add-on-watchdog'); ?></span>
     316            <span class="wp-watchdog-stat__value wp-watchdog-stat__value--text"><?php echo esc_html($watchdogLastScanValue); ?></span>
     317            <span class="wp-watchdog-stat__hint"><?php echo esc_html($watchdogLastScanHint); ?></span>
     318        </a>
     319        <a class="wp-watchdog-stat" href="#watchdog-settings">
     320            <span class="wp-watchdog-stat__icon dashicons dashicons-clock" aria-hidden="true"></span>
     321            <span class="wp-watchdog-stat__label"><?php esc_html_e('Next scan', 'site-add-on-watchdog'); ?></span>
     322            <span class="wp-watchdog-stat__value wp-watchdog-stat__value--text"><?php echo esc_html($watchdogNextScanValue); ?></span>
     323            <span class="wp-watchdog-stat__hint"><?php echo esc_html($watchdogNextScanHint); ?></span>
     324        </a>
     325        <a class="wp-watchdog-stat wp-watchdog-stat--<?php echo esc_attr($watchdogChannelTone); ?>" href="#watchdog-delivery">
     326            <span class="wp-watchdog-stat__icon dashicons dashicons-megaphone" aria-hidden="true"></span>
     327            <span class="wp-watchdog-stat__label"><?php esc_html_e('Active channels', 'site-add-on-watchdog'); ?></span>
     328            <span class="wp-watchdog-stat__value"><?php echo esc_html(number_format_i18n($watchdogEnabledChannelCount) . '/' . number_format_i18n(count($watchdogChannelLabels))); ?></span>
     329            <span class="wp-watchdog-stat__hint"><?php echo esc_html($watchdogChannelHint); ?></span>
     330        </a>
     331    </section>
     332
     333    <nav class="wp-watchdog-tabs" aria-label="<?php esc_attr_e('Watchdog sections', 'site-add-on-watchdog'); ?>">
     334        <?php foreach ($watchdogTabs as $watchdogTabId => $watchdogTabLabel) : ?>
     335            <a class="wp-watchdog-tabs__tab" href="#<?php echo esc_attr($watchdogTabId); ?>" data-watchdog-tab="<?php echo esc_attr($watchdogTabId); ?>">
     336                <?php echo esc_html($watchdogTabLabel); ?>
     337                <?php if ($watchdogTabId === 'watchdog-risks' && $watchdogRiskTotal > 0) : ?>
     338                    <span class="wp-watchdog-tabs__count"><?php echo esc_html(number_format_i18n($watchdogRiskTotal)); ?></span>
     339                <?php elseif ($watchdogTabId === 'watchdog-delivery' && ($watchdogHasFailedNotification || $watchdogQueueLength > 0)) : ?>
     340                    <span class="wp-watchdog-tabs__dot" aria-hidden="true"></span>
     341                    <span class="screen-reader-text"><?php esc_html_e('Needs attention', 'site-add-on-watchdog'); ?></span>
     342                <?php endif; ?>
     343            </a>
     344        <?php endforeach; ?>
     345    </nav>
     346
     347    <section class="wp-watchdog-panel" id="watchdog-risks" data-watchdog-panel>
     348        <div class="wp-watchdog-card">
     349            <div class="wp-watchdog-card__header">
     350                <div>
     351                    <h2 class="wp-watchdog-card__title"><?php esc_html_e('Potential Risks', 'site-add-on-watchdog'); ?></h2>
     352                    <p class="wp-watchdog-card__description"><?php esc_html_e('Plugins with a newer release, a security note in the latest changelog, or a known vulnerability.', 'site-add-on-watchdog'); ?></p>
     353                </div>
     354                <?php if ($watchdogRiskTotal > 0) : ?>
     355                    <a class="button" href="<?php echo esc_url(admin_url('update-core.php')); ?>"><?php esc_html_e('Review updates', 'site-add-on-watchdog'); ?></a>
     356                <?php endif; ?>
    237357            </div>
    238                 <?php $watchdogIsCronDisabled = ! empty($watchdogCronStatus['cron_disabled']); ?>
     358
     359            <?php if ($watchdogRiskTotal === 0) : ?>
     360                <div class="wp-watchdog-empty">
     361                    <span class="wp-watchdog-empty__icon dashicons <?php echo $watchdogLastScanAt === null ? 'dashicons-search' : 'dashicons-yes-alt'; ?>" aria-hidden="true"></span>
     362                    <?php if ($watchdogLastScanAt === null) : ?>
     363                        <p class="wp-watchdog-empty__title"><?php esc_html_e('No scan results yet', 'site-add-on-watchdog'); ?></p>
     364                        <p class="wp-watchdog-empty__text"><?php esc_html_e('Run a manual scan to check your plugins now.', 'site-add-on-watchdog'); ?></p>
     365                    <?php else : ?>
     366                        <p class="wp-watchdog-empty__title"><?php esc_html_e('No risks detected.', 'site-add-on-watchdog'); ?></p>
     367                        <p class="wp-watchdog-empty__text"><?php esc_html_e('Nothing was flagged in the latest scan.', 'site-add-on-watchdog'); ?></p>
     368                    <?php endif; ?>
     369                </div>
     370            <?php else : ?>
     371                <?php
     372                $watchdogColumns = [
     373                    'plugin'  => __('Plugin', 'site-add-on-watchdog'),
     374                    'local'   => __('Local Version', 'site-add-on-watchdog'),
     375                    'remote'  => __('Directory Version', 'site-add-on-watchdog'),
     376                    'reasons' => __('Reasons', 'site-add-on-watchdog'),
     377                    'actions' => __('Actions', 'site-add-on-watchdog'),
     378                ];
     379                $watchdogSortOptions = [
     380                    'plugin'      => ['sortPlugin', 'asc', __('Plugin name', 'site-add-on-watchdog')],
     381                    'risk_count'  => ['sortRiskCount', 'desc', __('Risk count', 'site-add-on-watchdog')],
     382                    'version_gap' => ['sortVersionGap', 'desc', __('Version gap', 'site-add-on-watchdog')],
     383                    'local'       => ['sortLocal', 'desc', __('Local version', 'site-add-on-watchdog')],
     384                    'remote'      => ['sortRemote', 'desc', __('Directory version', 'site-add-on-watchdog')],
     385                    'reasons'     => ['sortReasons', 'asc', __('Reasons', 'site-add-on-watchdog')],
     386                ];
     387                $watchdogSortableColumns = [
     388                    'plugin'  => ['sortPlugin', 'asc'],
     389                    'local'   => ['sortLocal', 'desc'],
     390                    'remote'  => ['sortRemote', 'desc'],
     391                    'reasons' => ['sortReasons', 'asc'],
     392                ];
     393                $watchdogPerPage = max(1, (int) apply_filters('site_add_on_watchdog_admin_risks_per_page', 10));
     394                ?>
     395                <div class="wp-watchdog-risk-table" data-wp-watchdog-table data-per-page="<?php echo esc_attr((string) $watchdogPerPage); ?>">
     396                    <div class="wp-watchdog-risk-table__controls">
     397                        <form method="get" class="wp-watchdog-risk-table__filter-form">
     398                            <input type="hidden" name="page" value="site-add-on-watchdog" />
     399                            <label class="wp-watchdog-risk-table__search">
     400                                <span class="screen-reader-text"><?php esc_html_e('Search risks', 'site-add-on-watchdog'); ?></span>
     401                                <input
     402                                    type="search"
     403                                    name="risk_search"
     404                                    value="<?php echo esc_attr($watchdogRiskSearch); ?>"
     405                                    placeholder="<?php esc_attr_e('Search plugin or reason', 'site-add-on-watchdog'); ?>"
     406                                    data-risk-search
     407                                />
     408                            </label>
     409                            <label class="screen-reader-text" for="wp-watchdog-risk-sort"><?php esc_html_e('Sort risks by', 'site-add-on-watchdog'); ?></label>
     410                            <select id="wp-watchdog-risk-sort" name="risk_sort" data-risk-sort>
     411                                <?php foreach ($watchdogSortOptions as $watchdogSortValue => [$watchdogSortKey, $watchdogSortDefault, $watchdogSortLabel]) : ?>
     412                                    <option
     413                                        value="<?php echo esc_attr($watchdogSortValue); ?>"
     414                                        data-sort-key="<?php echo esc_attr($watchdogSortKey); ?>"
     415                                        data-sort-default="<?php echo esc_attr($watchdogSortDefault); ?>"
     416                                        <?php selected($watchdogRiskSortSelection, $watchdogSortValue); ?>
     417                                    ><?php echo esc_html($watchdogSortLabel); ?></option>
     418                                <?php endforeach; ?>
     419                            </select>
     420                            <label class="screen-reader-text" for="wp-watchdog-risk-order"><?php esc_html_e('Sort order', 'site-add-on-watchdog'); ?></label>
     421                            <select id="wp-watchdog-risk-order" name="risk_order" data-risk-order>
     422                                <option value="asc" <?php selected($watchdogRiskOrderSelection, 'asc'); ?>><?php esc_html_e('Ascending', 'site-add-on-watchdog'); ?></option>
     423                                <option value="desc" <?php selected($watchdogRiskOrderSelection, 'desc'); ?>><?php esc_html_e('Descending', 'site-add-on-watchdog'); ?></option>
     424                            </select>
     425                            <button class="button wp-watchdog-no-js" type="submit"><?php esc_html_e('Apply', 'site-add-on-watchdog'); ?></button>
     426                        </form>
     427                        <div class="wp-watchdog-risk-table__pagination" data-pagination>
     428                            <button type="button" class="button" data-action="prev" aria-label="<?php esc_attr_e('Previous page', 'site-add-on-watchdog'); ?>" disabled>&lsaquo;</button>
     429                            <span class="wp-watchdog-risk-table__page-status" data-page-status aria-live="polite"></span>
     430                            <button type="button" class="button" data-action="next" aria-label="<?php esc_attr_e('Next page', 'site-add-on-watchdog'); ?>" disabled>&rsaquo;</button>
     431                        </div>
     432                    </div>
     433                    <div class="wp-watchdog-risk-table__scroll">
     434                        <table class="widefat striped">
     435                            <thead>
     436                            <tr>
     437                                <?php foreach ($watchdogSortableColumns as $watchdogColumnKey => [$watchdogSortKey, $watchdogSortDefault]) : ?>
     438                                    <th scope="col" class="wp-watchdog-risk-table__col-<?php echo esc_attr($watchdogColumnKey); ?>">
     439                                        <button
     440                                            type="button"
     441                                            class="wp-watchdog-risk-table__sort"
     442                                            data-sort-key="<?php echo esc_attr($watchdogSortKey); ?>"
     443                                            data-sort-default="<?php echo esc_attr($watchdogSortDefault); ?>"
     444                                            <?php echo $watchdogColumnKey === 'plugin' ? 'data-sort-initial' : ''; ?>
     445                                            aria-sort="none"
     446                                        >
     447                                            <?php echo esc_html($watchdogColumns[$watchdogColumnKey]); ?>
     448                                            <span class="wp-watchdog-risk-table__sort-indicator" aria-hidden="true"></span>
     449                                        </button>
     450                                    </th>
     451                                <?php endforeach; ?>
     452                                <th scope="col" class="wp-watchdog-risk-table__col-actions"><?php echo esc_html($watchdogColumns['actions']); ?></th>
     453                            </tr>
     454                            </thead>
     455                            <tbody>
     456                            <?php foreach ($watchdogRiskRows as $watchdogRiskRow) : ?>
     457                                <?php
     458                                $watchdogRisk = $watchdogRiskRow['risk'];
     459                                $watchdogVulnerabilities = $watchdogRiskRow['vulnerabilities'];
     460                                ?>
     461                                <tr
     462                                    <?php foreach ($watchdogRiskRow['attributes'] as $watchdogAttributeName => $watchdogAttributeValue) : ?>
     463                                        <?php echo esc_attr($watchdogAttributeName); ?>="<?php echo esc_attr($watchdogAttributeValue); ?>"
     464                                    <?php endforeach; ?>
     465                                >
     466                                    <td data-column="plugin" data-column-label="<?php echo esc_attr($watchdogColumns['plugin']); ?>">
     467                                        <strong><?php echo esc_html($watchdogRisk->pluginName); ?></strong>
     468                                    </td>
     469                                    <td data-column="local" data-column-label="<?php echo esc_attr($watchdogColumns['local']); ?>">
     470                                        <?php echo esc_html($watchdogRisk->localVersion); ?>
     471                                    </td>
     472                                    <td data-column="remote" data-column-label="<?php echo esc_attr($watchdogColumns['remote']); ?>">
     473                                        <?php echo esc_html($watchdogRisk->remoteVersion ?? __('N/A', 'site-add-on-watchdog')); ?>
     474                                    </td>
     475                                    <td data-column="reasons" data-column-label="<?php echo esc_attr($watchdogColumns['reasons']); ?>">
     476                                        <ul class="wp-watchdog-reasons">
     477                                            <?php foreach ($watchdogRisk->reasons as $watchdogReason) : ?>
     478                                                <li><?php echo esc_html($watchdogReason); ?></li>
     479                                            <?php endforeach; ?>
     480                                        </ul>
     481                                        <?php if ($watchdogVulnerabilities !== []) : ?>
     482                                            <ul class="wp-watchdog-vulnerabilities" aria-label="<?php esc_attr_e('WPScan vulnerabilities:', 'site-add-on-watchdog'); ?>">
     483                                                <?php foreach ($watchdogVulnerabilities as $watchdogVuln) : ?>
     484                                                    <li>
     485                                                        <?php if (! empty($watchdogVuln['severity']) && ! empty($watchdogVuln['severity_label'])) : ?>
     486                                                            <span class="wp-watchdog-severity wp-watchdog-severity--<?php echo esc_attr(sanitize_html_class((string) $watchdogVuln['severity'])); ?>"><?php echo esc_html($watchdogVuln['severity_label']); ?></span>
     487                                                        <?php endif; ?>
     488                                                        <?php if (! empty($watchdogVuln['title'])) : ?>
     489                                                            <span class="wp-watchdog-vulnerability__title"><?php echo esc_html($watchdogVuln['title']); ?></span>
     490                                                        <?php endif; ?>
     491                                                        <?php if (! empty($watchdogVuln['cve'])) : ?>
     492                                                            <span class="wp-watchdog-vulnerability__meta"><?php echo esc_html($watchdogVuln['cve']); ?></span>
     493                                                        <?php endif; ?>
     494                                                        <?php if (! empty($watchdogVuln['fixed_in'])) : ?>
     495                                                            <span class="wp-watchdog-vulnerability__meta">
     496                                                                <?php
     497                                                                printf(
     498                                                                    /* translators: %s: plugin version number. */
     499                                                                    esc_html__('Fixed in %s', 'site-add-on-watchdog'),
     500                                                                    esc_html($watchdogVuln['fixed_in'])
     501                                                                );
     502                                                                ?>
     503                                                            </span>
     504                                                        <?php endif; ?>
     505                                                    </li>
     506                                                <?php endforeach; ?>
     507                                            </ul>
     508                                        <?php endif; ?>
     509                                    </td>
     510                                    <td data-column="actions" data-column-label="<?php echo esc_attr($watchdogColumns['actions']); ?>">
     511                                        <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
     512                                            <?php wp_nonce_field($watchdogActionPrefix . '_ignore'); ?>
     513                                            <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_ignore'); ?>">
     514                                            <input type="hidden" name="plugin_slug" value="<?php echo esc_attr($watchdogRisk->pluginSlug); ?>">
     515                                            <button class="button button-small" type="submit"><?php esc_html_e('Ignore', 'site-add-on-watchdog'); ?></button>
     516                                        </form>
     517                                    </td>
     518                                </tr>
     519                            <?php endforeach; ?>
     520                            </tbody>
     521                        </table>
     522                    </div>
     523                </div>
     524            <?php endif; ?>
     525        </div>
     526
     527        <div class="wp-watchdog-card" id="watchdog-ignored">
     528            <h2 class="wp-watchdog-card__title"><?php esc_html_e('Ignored Plugins', 'site-add-on-watchdog'); ?></h2>
     529            <?php if (empty($watchdogIgnored)) : ?>
     530                <p class="wp-watchdog-card__description"><?php esc_html_e('No plugins are being ignored.', 'site-add-on-watchdog'); ?></p>
     531            <?php else : ?>
     532                <p class="wp-watchdog-card__description"><?php esc_html_e('Ignored plugins are skipped by every scan. Select one to watch it again.', 'site-add-on-watchdog'); ?></p>
     533                <ul class="wp-watchdog-chips">
     534                    <?php foreach ($watchdogIgnored as $watchdogIgnoredSlug) : ?>
     535                        <li>
     536                            <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
     537                                <?php wp_nonce_field($watchdogActionPrefix . '_unignore'); ?>
     538                                <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_unignore'); ?>">
     539                                <input type="hidden" name="plugin_slug" value="<?php echo esc_attr($watchdogIgnoredSlug); ?>">
     540                                <button class="wp-watchdog-chip" type="submit">
     541                                    <?php echo esc_html($watchdogIgnoredSlug); ?>
     542                                    <span class="dashicons dashicons-no-alt" aria-hidden="true"></span>
     543                                    <span class="screen-reader-text"><?php esc_html_e('Stop ignoring', 'site-add-on-watchdog'); ?></span>
     544                                </button>
     545                            </form>
     546                        </li>
     547                    <?php endforeach; ?>
     548                </ul>
     549            <?php endif; ?>
     550        </div>
     551    </section>
     552
     553    <section class="wp-watchdog-panel" id="watchdog-history" data-watchdog-panel>
     554        <div class="wp-watchdog-card">
     555            <h2 class="wp-watchdog-card__title"><?php esc_html_e('Scan History', 'site-add-on-watchdog'); ?></h2>
     556            <?php require __DIR__ . '/history.php'; ?>
     557        </div>
     558    </section>
     559
     560    <section class="wp-watchdog-panel" id="watchdog-settings" data-watchdog-panel>
     561        <form class="wp-watchdog-settings-form" method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
     562            <?php wp_nonce_field($watchdogActionPrefix . '_settings'); ?>
     563            <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_save_settings'); ?>">
     564
     565            <div class="wp-watchdog-card">
     566                <h2 class="wp-watchdog-card__title"><?php esc_html_e('Scanning', 'site-add-on-watchdog'); ?></h2>
     567                <?php
     568                $watchdogDefaultFrequencyMessage = __('Choose how often the automatic scan should run.', 'site-add-on-watchdog');
     569                $watchdogTestingFrequencyMessage = sprintf(
     570                    /* translators: 1: interval minutes, 2: duration hours */
     571                    __('Testing mode sends notifications every %1$d minutes to all configured channels and automatically switches back to daily scans after %2$d hours.', 'site-add-on-watchdog'),
     572                    TestingMode::INTERVAL_MINUTES,
     573                    TestingMode::DURATION_HOURS
     574                );
     575                $watchdogIsTestingFrequency = $watchdogCurrentFrequency === 'testing';
     576                $watchdogTestingExpiresAt   = (int) ($watchdogNotifications['testing_expires_at'] ?? 0);
     577                $watchdogDailyTime          = (string) ($watchdogNotifications['daily_time'] ?? '08:00');
     578                $watchdogWeeklyDay          = (int) ($watchdogNotifications['weekly_day'] ?? 1);
     579                $watchdogWeeklyTime         = (string) ($watchdogNotifications['weekly_time'] ?? '08:00');
     580                $watchdogWeekdays           = [
     581                    1 => __('Monday', 'site-add-on-watchdog'),
     582                    2 => __('Tuesday', 'site-add-on-watchdog'),
     583                    3 => __('Wednesday', 'site-add-on-watchdog'),
     584                    4 => __('Thursday', 'site-add-on-watchdog'),
     585                    5 => __('Friday', 'site-add-on-watchdog'),
     586                    6 => __('Saturday', 'site-add-on-watchdog'),
     587                    7 => __('Sunday', 'site-add-on-watchdog'),
     588                ];
     589                ?>
     590                <div class="wp-watchdog-field">
     591                    <label class="wp-watchdog-field__label" for="wp-watchdog-notification-frequency"><?php esc_html_e('Scan frequency', 'site-add-on-watchdog'); ?></label>
     592                    <div class="wp-watchdog-field__control">
     593                        <select id="wp-watchdog-notification-frequency" name="settings[notifications][frequency]">
     594                            <option value="daily" <?php selected($watchdogCurrentFrequency, 'daily'); ?>><?php esc_html_e('Daily', 'site-add-on-watchdog'); ?></option>
     595                            <option value="weekly" <?php selected($watchdogCurrentFrequency, 'weekly'); ?>><?php esc_html_e('Weekly', 'site-add-on-watchdog'); ?></option>
     596                            <option value="testing" <?php selected($watchdogCurrentFrequency, 'testing'); ?>>
     597                                <?php
     598                                printf(
     599                                    /* translators: %d: interval minutes */
     600                                    esc_html__('Testing (every %d minutes)', 'site-add-on-watchdog'),
     601                                    (int) TestingMode::INTERVAL_MINUTES
     602                                );
     603                                ?>
     604                            </option>
     605                            <option value="manual" <?php selected($watchdogCurrentFrequency, 'manual'); ?>><?php esc_html_e('Manual (no automatic scans)', 'site-add-on-watchdog'); ?></option>
     606                        </select>
     607                        <p
     608                            class="description wp-watchdog-frequency-description<?php echo $watchdogIsTestingFrequency ? ' wp-watchdog-frequency-description--testing' : ''; ?>"
     609                            data-watchdog-frequency-description
     610                            data-default-message="<?php echo esc_attr($watchdogDefaultFrequencyMessage); ?>"
     611                            data-testing-message="<?php echo esc_attr($watchdogTestingFrequencyMessage); ?>"
     612                            aria-live="polite"
     613                        >
     614                            <?php echo esc_html($watchdogIsTestingFrequency ? $watchdogTestingFrequencyMessage : $watchdogDefaultFrequencyMessage); ?>
     615                        </p>
     616                        <?php if ($watchdogIsTestingFrequency && $watchdogTestingExpiresAt > $watchdogNow) : ?>
     617                            <p class="description">
     618                                <?php
     619                                printf(
     620                                    /* translators: 1: datetime, 2: relative time */
     621                                    esc_html__('Testing mode will automatically switch back to daily scans on %1$s (%2$s remaining).', 'site-add-on-watchdog'),
     622                                    esc_html(wp_date($watchdogDateFormat, $watchdogTestingExpiresAt)),
     623                                    esc_html(human_time_diff($watchdogNow, $watchdogTestingExpiresAt))
     624                                );
     625                                ?>
     626                            </p>
     627                        <?php endif; ?>
     628                    </div>
     629                </div>
     630                <div data-watchdog-frequency-options>
     631                    <div class="wp-watchdog-field" data-watchdog-frequency-target="daily">
     632                        <label class="wp-watchdog-field__label" for="wp-watchdog-daily-time"><?php esc_html_e('Daily send time', 'site-add-on-watchdog'); ?></label>
     633                        <div class="wp-watchdog-field__control">
     634                            <input
     635                                id="wp-watchdog-daily-time"
     636                                name="settings[notifications][daily_time]"
     637                                type="time"
     638                                value="<?php echo esc_attr($watchdogDailyTime); ?>"
     639                                aria-describedby="wp-watchdog-daily-time-help"
     640                            />
     641                            <p class="description" id="wp-watchdog-daily-time-help"><?php esc_html_e('Time of day to start the daily scan in the WordPress site timezone.', 'site-add-on-watchdog'); ?></p>
     642                        </div>
     643                    </div>
     644                    <div class="wp-watchdog-field" data-watchdog-frequency-target="weekly">
     645                        <label class="wp-watchdog-field__label" for="wp-watchdog-weekly-day"><?php esc_html_e('Weekly send day', 'site-add-on-watchdog'); ?></label>
     646                        <div class="wp-watchdog-field__control">
     647                            <div class="wp-watchdog-field__inline">
     648                                <select id="wp-watchdog-weekly-day" name="settings[notifications][weekly_day]">
     649                                    <?php foreach ($watchdogWeekdays as $watchdogWeekdayNumber => $watchdogWeekdayLabel) : ?>
     650                                        <option value="<?php echo esc_attr((string) $watchdogWeekdayNumber); ?>" <?php selected($watchdogWeeklyDay, $watchdogWeekdayNumber); ?>><?php echo esc_html($watchdogWeekdayLabel); ?></option>
     651                                    <?php endforeach; ?>
     652                                </select>
     653                                <label class="screen-reader-text" for="wp-watchdog-weekly-time"><?php esc_html_e('Weekly send time', 'site-add-on-watchdog'); ?></label>
     654                                <input
     655                                    id="wp-watchdog-weekly-time"
     656                                    name="settings[notifications][weekly_time]"
     657                                    type="time"
     658                                    value="<?php echo esc_attr($watchdogWeeklyTime); ?>"
     659                                    aria-describedby="wp-watchdog-weekly-time-help"
     660                                />
     661                            </div>
     662                            <p class="description" id="wp-watchdog-weekly-time-help"><?php esc_html_e('Day and time to start the weekly scan in the WordPress site timezone.', 'site-add-on-watchdog'); ?></p>
     663                        </div>
     664                    </div>
     665                </div>
     666                <div class="wp-watchdog-field">
     667                    <label class="wp-watchdog-field__label" for="wp-watchdog-history-retention"><?php esc_html_e('History retention', 'site-add-on-watchdog'); ?></label>
     668                    <div class="wp-watchdog-field__control">
     669                        <input
     670                            type="number"
     671                            id="wp-watchdog-history-retention"
     672                            class="small-text"
     673                            name="settings[history][retention]"
     674                            value="<?php echo esc_attr((string) ($watchdogSettings['history']['retention'] ?? $watchdogHistoryRetention)); ?>"
     675                            min="1"
     676                            max="15"
     677                            step="1"
     678                        />
     679                        <p class="description"><?php esc_html_e('Number of recent scans to keep available for review and download (maximum 15).', 'site-add-on-watchdog'); ?></p>
     680                    </div>
     681                </div>
     682            </div>
     683
     684            <?php
     685            $watchdogChannels = [
     686                'email' => [
     687                    'icon'   => 'dashicons-email-alt',
     688                    'test'   => __('Save and test email', 'site-add-on-watchdog'),
     689                    'fields' => [
     690                        'recipients' => ['text', __('Recipients (comma separated)', 'site-add-on-watchdog')],
     691                    ],
     692                    'notes'  => [
     693                        __('Separate addresses with commas, semicolons, or spaces. WordPress administrator accounts are always included.', 'site-add-on-watchdog'),
     694                        __('Email uses WordPress mail delivery. Your host must support sending mail, or you must configure an SMTP/mail plugin. Discord and other webhooks do not use this mail configuration.', 'site-add-on-watchdog'),
     695                    ],
     696                ],
     697                'discord' => [
     698                    'icon'   => 'dashicons-format-chat',
     699                    'test'   => __('Save and test Discord', 'site-add-on-watchdog'),
     700                    'fields' => [
     701                        'webhook' => ['url', __('Discord webhook URL', 'site-add-on-watchdog')],
     702                    ],
     703                    'notes'  => [],
     704                ],
     705                'slack' => [
     706                    'icon'   => 'dashicons-admin-comments',
     707                    'test'   => __('Save and test Slack', 'site-add-on-watchdog'),
     708                    'fields' => [
     709                        'webhook' => ['url', __('Slack webhook URL', 'site-add-on-watchdog')],
     710                    ],
     711                    'notes'  => [],
     712                ],
     713                'teams' => [
     714                    'icon'   => 'dashicons-groups',
     715                    'test'   => __('Save and test Teams', 'site-add-on-watchdog'),
     716                    'fields' => [
     717                        'webhook' => ['url', __('Teams webhook URL', 'site-add-on-watchdog')],
     718                    ],
     719                    'notes'  => [
     720                        __('Supports Microsoft Teams Workflows and existing Incoming Webhook connectors.', 'site-add-on-watchdog'),
     721                    ],
     722                ],
     723                'webhook' => [
     724                    'icon'   => 'dashicons-rest-api',
     725                    'test'   => __('Save and test webhook', 'site-add-on-watchdog'),
     726                    'fields' => [
     727                        'url'    => ['url', __('Webhook URL', 'site-add-on-watchdog')],
     728                        'secret' => ['password', __('Webhook secret (optional)', 'site-add-on-watchdog')],
     729                    ],
     730                    'notes'  => [
     731                        __('Signs the exact JSON body with HMAC-SHA256 in the X-Watchdog-Signature header. Test messages are marked in the payload.', 'site-add-on-watchdog'),
     732                    ],
     733                ],
     734            ];
     735            ?>
     736            <div class="wp-watchdog-card">
     737                <h2 class="wp-watchdog-card__title"><?php esc_html_e('Notification channels', 'site-add-on-watchdog'); ?></h2>
     738                <p class="wp-watchdog-card__description"><?php esc_html_e('Save and test buttons save all settings on this form, then send a test to the selected channel.', 'site-add-on-watchdog'); ?></p>
     739                <div class="wp-watchdog-channels">
     740                    <?php foreach ($watchdogChannels as $watchdogChannelKey => $watchdogChannel) : ?>
     741                        <?php
     742                        $watchdogChannelSettings = is_array($watchdogNotifications[$watchdogChannelKey] ?? null)
     743                            ? $watchdogNotifications[$watchdogChannelKey]
     744                            : [];
     745                        $watchdogChannelName = 'settings[notifications][' . $watchdogChannelKey . ']';
     746                        ?>
     747                        <div class="wp-watchdog-channel" data-watchdog-notification>
     748                            <div class="wp-watchdog-channel__header">
     749                                <span class="wp-watchdog-channel__icon dashicons <?php echo esc_attr($watchdogChannel['icon']); ?>" aria-hidden="true"></span>
     750                                <h3 class="wp-watchdog-channel__title"><?php echo esc_html($watchdogChannelLabels[$watchdogChannelKey]); ?></h3>
     751                                <label class="wp-watchdog-switch">
     752                                    <input
     753                                        type="checkbox"
     754                                        name="<?php echo esc_attr($watchdogChannelName . '[enabled]'); ?>"
     755                                        <?php checked(! empty($watchdogChannelSettings['enabled'])); ?>
     756                                        data-watchdog-toggle
     757                                    />
     758                                    <span
     759                                        class="wp-watchdog-switch__state"
     760                                        data-on="<?php esc_attr_e('On', 'site-add-on-watchdog'); ?>"
     761                                        data-off="<?php esc_attr_e('Off', 'site-add-on-watchdog'); ?>"
     762                                        aria-hidden="true"
     763                                    ></span>
     764                                    <span class="screen-reader-text">
     765                                        <?php
     766                                        printf(
     767                                            /* translators: %s: notification channel name. */
     768                                            esc_html__('Enable %s notifications', 'site-add-on-watchdog'),
     769                                            esc_html($watchdogChannelLabels[$watchdogChannelKey])
     770                                        );
     771                                        ?>
     772                                    </span>
     773                                </label>
     774                            </div>
     775                            <div class="wp-watchdog-channel__body" data-watchdog-fields>
     776                                <?php foreach ($watchdogChannel['fields'] as $watchdogFieldKey => [$watchdogFieldType, $watchdogFieldLabel]) : ?>
     777                                    <?php $watchdogFieldId = 'wp-watchdog-' . $watchdogChannelKey . '-' . $watchdogFieldKey; ?>
     778                                    <p class="wp-watchdog-channel__field">
     779                                        <label for="<?php echo esc_attr($watchdogFieldId); ?>"><?php echo esc_html($watchdogFieldLabel); ?></label>
     780                                        <input
     781                                            type="<?php echo esc_attr($watchdogFieldType); ?>"
     782                                            id="<?php echo esc_attr($watchdogFieldId); ?>"
     783                                            class="regular-text"
     784                                            name="<?php echo esc_attr($watchdogChannelName . '[' . $watchdogFieldKey . ']'); ?>"
     785                                            value="<?php echo esc_attr((string) ($watchdogChannelSettings[$watchdogFieldKey] ?? '')); ?>"
     786                                            <?php echo $watchdogFieldType === 'password' ? 'autocomplete="new-password"' : ''; ?>
     787                                        />
     788                                    </p>
     789                                <?php endforeach; ?>
     790                                <?php foreach ($watchdogChannel['notes'] as $watchdogChannelNote) : ?>
     791                                    <p class="description"><?php echo esc_html($watchdogChannelNote); ?></p>
     792                                <?php endforeach; ?>
     793                                <p class="wp-watchdog-channel__actions">
     794                                    <button class="button" type="submit" name="test_channel" value="<?php echo esc_attr($watchdogChannelKey); ?>"><?php echo esc_html($watchdogChannel['test']); ?></button>
     795                                </p>
     796                            </div>
     797                        </div>
     798                    <?php endforeach; ?>
     799                </div>
     800            </div>
     801
     802            <div class="wp-watchdog-card">
     803                <h2 class="wp-watchdog-card__title"><?php esc_html_e('Vulnerability data', 'site-add-on-watchdog'); ?></h2>
     804                <div class="wp-watchdog-field">
     805                    <label class="wp-watchdog-field__label" for="wp-watchdog-wpscan-api-key"><?php esc_html_e('WPScan API key', 'site-add-on-watchdog'); ?></label>
     806                    <div class="wp-watchdog-field__control">
     807                        <input
     808                            type="password"
     809                            id="wp-watchdog-wpscan-api-key"
     810                            class="regular-text"
     811                            name="settings[notifications][wpscan_api_key]"
     812                            value="<?php echo esc_attr((string) ($watchdogNotifications['wpscan_api_key'] ?? '')); ?>"
     813                            autocomplete="new-password"
     814                        />
     815                        <p class="description"><?php esc_html_e('Optional. Provide your own WPScan API key to enrich vulnerability reports.', 'site-add-on-watchdog'); ?></p>
     816                    </div>
     817                </div>
     818            </div>
     819
     820            <p class="wp-watchdog-settings-form__submit">
     821                <button class="button button-primary" type="submit"><?php esc_html_e('Save settings', 'site-add-on-watchdog'); ?></button>
     822            </p>
     823        </form>
     824    </section>
     825
     826    <section class="wp-watchdog-panel" id="watchdog-delivery" data-watchdog-panel>
     827        <div class="wp-watchdog-card">
     828            <div class="wp-watchdog-card__header">
     829                <h2 class="wp-watchdog-card__title"><?php esc_html_e('Scheduler', 'site-add-on-watchdog'); ?></h2>
     830                <?php if ($watchdogIsCronDisabled) : ?>
     831                    <span class="wp-watchdog-badge wp-watchdog-badge--warn"><?php esc_html_e('WP-Cron disabled', 'site-add-on-watchdog'); ?></span>
     832                <?php else : ?>
     833                    <span class="wp-watchdog-badge wp-watchdog-badge--ok"><?php esc_html_e('WP-Cron enabled', 'site-add-on-watchdog'); ?></span>
     834                <?php endif; ?>
     835            </div>
    239836            <?php if ($watchdogIsCronDisabled) : ?>
    240                 <p class="wp-watchdog-muted"><?php esc_html_e('WP-Cron appears disabled. Use a real cron job or the server endpoint below to keep scans running.', 'site-add-on-watchdog'); ?></p>
    241                 <form class="wp-watchdog-block-action" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    242                     <?php wp_nonce_field($watchdogActionPrefix . '_send_notifications'); ?>
    243                     <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_send_notifications'); ?>">
    244                     <input type="hidden" name="force" value="1" />
    245                     <input type="hidden" name="ignore_throttle" value="1" />
    246                     <button class="button button-primary" type="submit">
    247                         <span class="dashicons dashicons-controls-repeat" aria-hidden="true"></span>
    248                         <?php esc_html_e('Retry notifications', 'site-add-on-watchdog'); ?>
    249                     </button>
    250                 </form>
     837                <p class="wp-watchdog-card__description"><?php esc_html_e('WP-Cron appears disabled. Use a real cron job or the server endpoint below to keep scans running.', 'site-add-on-watchdog'); ?></p>
    251838            <?php else : ?>
    252                 <p class="wp-watchdog-muted"><?php esc_html_e('WP-Cron is enabled. Scheduled tasks depend on site traffic or a server cron job.', 'site-add-on-watchdog'); ?></p>
     839                <p class="wp-watchdog-card__description"><?php esc_html_e('WP-Cron is enabled. Scheduled tasks depend on site traffic or a server cron job.', 'site-add-on-watchdog'); ?></p>
    253840            <?php endif; ?>
    254             <div class="wp-watchdog-divider"></div>
    255             <p class="wp-watchdog-muted wp-watchdog-endpoint">
    256                 <strong><?php esc_html_e('Server cron endpoint:', 'site-add-on-watchdog'); ?></strong><br />
    257                 <code><?php echo esc_html($watchdogCronEndpoint); ?></code>
    258             </p>
    259             <?php if ($watchdogCronSecretPersisted) : ?>
    260                 <p class="wp-watchdog-muted wp-watchdog-endpoint">
    261                     <strong><?php esc_html_e('Generated Cron secret:', 'site-add-on-watchdog'); ?></strong><br />
    262                     <code><?php echo esc_html($watchdogCronSecret); ?></code>
    263                 </p>
    264                 <p class="wp-watchdog-muted wp-watchdog-endpoint">
    265                     <strong><?php esc_html_e('Authenticated command:', 'site-add-on-watchdog'); ?></strong><br />
    266                     <code><?php echo esc_html(sprintf('curl -X POST -H "X-Watchdog-Cron-Key: %s" "%s"', $watchdogCronSecret, $watchdogCronEndpoint)); ?></code>
    267                 </p>
    268                 <p class="wp-watchdog-muted"><?php esc_html_e('Use POST and keep the generated secret private. The header-authenticated request can trigger scans or notification retries even when wp-cron is disabled.', 'site-add-on-watchdog'); ?></p>
    269             <?php endif; ?>
    270             <div class="wp-watchdog-divider"></div>
    271             <div>
    272                 <p class="wp-watchdog-muted wp-watchdog-subheading"><strong><?php esc_html_e('Queue status', 'site-add-on-watchdog'); ?></strong></p>
    273                 <?php
    274                 $watchdogQueueNextAttempt = $watchdogQueueStatus['next_attempt_at'] ?? null;
    275                 ?>
    276                 <p class="wp-watchdog-muted">
    277                     <?php
    278                     printf(
    279                         /* translators: %d: number of queued jobs. */
    280                         esc_html__('Queued jobs: %d', 'site-add-on-watchdog'),
    281                         esc_html(number_format_i18n($watchdogQueueLength))
    282                     );
    283                     ?>
    284                 </p>
     841
     842            <details class="wp-watchdog-details">
     843                <summary><?php esc_html_e('Trigger scans from a server cron job', 'site-add-on-watchdog'); ?></summary>
     844                <div class="wp-watchdog-details__body">
     845                    <div class="wp-watchdog-copy">
     846                        <span class="wp-watchdog-copy__label"><?php esc_html_e('Server cron endpoint:', 'site-add-on-watchdog'); ?></span>
     847                        <code id="wp-watchdog-cron-endpoint"><?php echo esc_html($watchdogCronEndpoint); ?></code>
     848                        <button type="button" class="button button-small" data-watchdog-copy="wp-watchdog-cron-endpoint" hidden><?php esc_html_e('Copy', 'site-add-on-watchdog'); ?></button>
     849                    </div>
     850                    <?php if ($watchdogCronSecretPersisted) : ?>
     851                        <div class="wp-watchdog-copy">
     852                            <span class="wp-watchdog-copy__label"><?php esc_html_e('Generated Cron secret:', 'site-add-on-watchdog'); ?></span>
     853                            <code id="wp-watchdog-cron-secret"><?php echo esc_html($watchdogCronSecret); ?></code>
     854                            <button type="button" class="button button-small" data-watchdog-copy="wp-watchdog-cron-secret" hidden><?php esc_html_e('Copy', 'site-add-on-watchdog'); ?></button>
     855                        </div>
     856                        <div class="wp-watchdog-copy">
     857                            <span class="wp-watchdog-copy__label"><?php esc_html_e('Authenticated command:', 'site-add-on-watchdog'); ?></span>
     858                            <code id="wp-watchdog-cron-command"><?php echo esc_html(sprintf('curl -X POST -H "X-Watchdog-Cron-Key: %s" "%s"', $watchdogCronSecret, $watchdogCronEndpoint)); ?></code>
     859                            <button type="button" class="button button-small" data-watchdog-copy="wp-watchdog-cron-command" hidden><?php esc_html_e('Copy', 'site-add-on-watchdog'); ?></button>
     860                        </div>
     861                        <p class="description"><?php esc_html_e('Use POST and keep the generated secret private. The header-authenticated request can trigger scans or notification retries even when wp-cron is disabled.', 'site-add-on-watchdog'); ?></p>
     862                    <?php endif; ?>
     863                </div>
     864            </details>
     865        </div>
     866
     867        <div class="wp-watchdog-card-grid">
     868            <div class="wp-watchdog-card">
     869                <div class="wp-watchdog-card__header">
     870                    <h2 class="wp-watchdog-card__title"><?php esc_html_e('Queue status', 'site-add-on-watchdog'); ?></h2>
     871                    <span class="wp-watchdog-badge <?php echo $watchdogQueueLength > 0 ? 'wp-watchdog-badge--warn' : 'wp-watchdog-badge--ok'; ?>">
     872                        <?php
     873                        printf(
     874                            /* translators: %s: number of queued jobs. */
     875                            esc_html__('Queued jobs: %s', 'site-add-on-watchdog'),
     876                            esc_html(number_format_i18n($watchdogQueueLength))
     877                        );
     878                        ?>
     879                    </span>
     880                </div>
    285881                <?php if ($watchdogQueueLength > 0 && $watchdogQueueNextAttempt) : ?>
    286                     <p class="wp-watchdog-muted">
     882                    <p class="wp-watchdog-card__description">
    287883                        <?php
    288884                        printf(
    289885                            /* translators: 1: scheduled time */
    290886                            esc_html__('Next attempt: %1$s', 'site-add-on-watchdog'),
    291                             esc_html(wp_date(get_option('date_format') . ' ' . get_option('time_format'), (int) $watchdogQueueNextAttempt))
     887                            esc_html(wp_date($watchdogDateFormat, (int) $watchdogQueueNextAttempt))
    292888                        );
    293889                        ?>
    294890                    </p>
    295891                <?php else : ?>
    296                     <p class="wp-watchdog-muted"><?php esc_html_e('No pending notifications in the queue.', 'site-add-on-watchdog'); ?></p>
     892                    <p class="wp-watchdog-card__description"><?php esc_html_e('No pending notifications in the queue.', 'site-add-on-watchdog'); ?></p>
    297893                <?php endif; ?>
    298894            </div>
    299             <div class="wp-watchdog-divider"></div>
    300             <div>
    301                 <p class="wp-watchdog-muted wp-watchdog-subheading"><strong><?php esc_html_e('Captured notification payloads', 'site-add-on-watchdog'); ?></strong></p>
    302                 <?php if (! empty($watchdogLastFailedNotification)) : ?>
     895
     896            <div class="wp-watchdog-card">
     897                <div class="wp-watchdog-card__header">
     898                    <h2 class="wp-watchdog-card__title"><?php esc_html_e('Captured notification payloads', 'site-add-on-watchdog'); ?></h2>
     899                    <?php if ($watchdogHasFailedNotification) : ?>
     900                        <span class="wp-watchdog-badge wp-watchdog-badge--danger"><?php esc_html_e('Delivery failed', 'site-add-on-watchdog'); ?></span>
     901                    <?php endif; ?>
     902                </div>
     903                <?php if ($watchdogHasFailedNotification) : ?>
    303904                    <?php
    304905                    $watchdogFailedTime = isset($watchdogLastFailedNotification['failed_at'])
    305906                        ? (int) $watchdogLastFailedNotification['failed_at']
    306                         : time();
     907                        : $watchdogNow;
    307908                    $watchdogFailedChannel = $watchdogLastFailedNotification['description']
    308909                        ?: ($watchdogLastFailedNotification['channel'] ?? __('Unknown channel', 'site-add-on-watchdog'));
    309                     $watchdogFailedError = $watchdogLastFailedNotification['last_error'] ?? '';
     910                    $watchdogFailedError = (string) ($watchdogLastFailedNotification['last_error'] ?? '');
    310911                    ?>
    311                     <p class="wp-watchdog-muted">
     912                    <p class="wp-watchdog-card__description">
    312913                        <?php
    313914                        printf(
    314915                            /* translators: 1: human readable time, 2: channel name */
    315916                            esc_html__('Last failure recorded %1$s via %2$s.', 'site-add-on-watchdog'),
    316                             esc_html(wp_date(get_option('date_format') . ' ' . get_option('time_format'), $watchdogFailedTime)),
     917                            esc_html(wp_date($watchdogDateFormat, $watchdogFailedTime)),
    317918                            esc_html($watchdogFailedChannel)
    318919                        );
    319                         if ($watchdogFailedError !== '') {
    320                             echo '<br />' . esc_html($watchdogFailedError);
    321                         }
    322920                        ?>
    323921                    </p>
     922                    <?php if ($watchdogFailedError !== '') : ?>
     923                        <p class="wp-watchdog-error-text"><?php echo esc_html($watchdogFailedError); ?></p>
     924                    <?php endif; ?>
    324925                    <div class="wp-watchdog-actions">
    325                         <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
     926                        <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>">
    326927                            <?php wp_nonce_field($watchdogActionPrefix . '_resend_failed_notification'); ?>
    327928                            <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_resend_failed_notification'); ?>" />
    … …  
    331932                            </button>
    332933                        </form>
    333                         <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
     934                        <form method="post" action="<?php echo esc_url($watchdogPostUrl); ?>" data-watchdog-download>
    334935                            <?php wp_nonce_field($watchdogActionPrefix . '_download_failed_notification'); ?>
    335936                            <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_download_failed_notification'); ?>" />
    … …  
    341942                    </div>
    342943                <?php else : ?>
    343                     <p class="wp-watchdog-muted"><?php esc_html_e('No failed notification payload has been captured yet.', 'site-add-on-watchdog'); ?></p>
     944                    <p class="wp-watchdog-card__description"><?php esc_html_e('No failed notification payload has been captured yet.', 'site-add-on-watchdog'); ?></p>
    344945                <?php endif; ?>
    345946            </div>
    346947        </div>
    347     </div>
    348 
    349     <div class="wp-watchdog-grid">
    350         <div class="wp-watchdog-surface" id="watchdog-history">
    351             <div class="wp-watchdog-section-title">
    352                 <span class="dashicons dashicons-list-view" aria-hidden="true"></span>
    353                 <?php esc_html_e('Scan History', 'site-add-on-watchdog'); ?>
    354             </div>
    355             <?php require __DIR__ . '/history.php'; ?>
    356         </div>
    357         <div class="wp-watchdog-surface" id="watchdog-channels">
    358             <div class="wp-watchdog-section-title">
    359                 <span class="dashicons dashicons-format-status" aria-hidden="true"></span>
    360                 <?php esc_html_e('Notification channels', 'site-add-on-watchdog'); ?>
    361             </div>
    362             <?php
    363             $watchdogChannels = [
    364                 'email'   => __('Email', 'site-add-on-watchdog'),
    365                 'slack'   => __('Slack', 'site-add-on-watchdog'),
    366                 'teams'   => __('Microsoft Teams', 'site-add-on-watchdog'),
    367                 'discord' => __('Discord', 'site-add-on-watchdog'),
    368                 'webhook' => __('Custom Webhook', 'site-add-on-watchdog'),
    369             ];
    370             ?>
    371             <ul class="wp-watchdog-inline-list">
    372                 <?php foreach ($watchdogChannels as $watchdogChannelKey => $watchdogChannelLabel) : ?>
    373                     <?php $watchdogChannelEnabled = ! empty($watchdogSettings['notifications'][$watchdogChannelKey]['enabled']); ?>
    374                     <?php $watchdogBadgeClass = $watchdogChannelEnabled ? 'wp-watchdog-badge--success' : 'wp-watchdog-badge--muted'; ?>
    375                     <li>
    376                         <span class="wp-watchdog-badge <?php echo esc_attr($watchdogBadgeClass); ?>">
    377                             <span class="dashicons <?php echo $watchdogChannelEnabled ? 'dashicons-yes-alt' : 'dashicons-dismiss'; ?>" aria-hidden="true"></span>
    378                             <?php echo esc_html($watchdogChannelLabel); ?>
    379                             <span aria-hidden="true">•</span>
    380                             <?php echo $watchdogChannelEnabled ? esc_html__('On', 'site-add-on-watchdog') : esc_html__('Off', 'site-add-on-watchdog'); ?>
    381                         </span>
    382                     </li>
    383                 <?php endforeach; ?>
    384             </ul>
    385             <p class="wp-watchdog-muted wp-watchdog-channel-summary">
    386                 <?php esc_html_e('Keep channels enabled to receive instant alerts and download-ready reports.', 'site-add-on-watchdog'); ?>
    387             </p>
    388         </div>
    389     </div>
    390 
    391     <div class="wp-watchdog-surface" id="watchdog-risks">
    392         <div class="wp-watchdog-section-header">
    393             <div class="wp-watchdog-section-title">
    394                 <span class="dashicons dashicons-shield-alt" aria-hidden="true"></span>
    395                 <?php esc_html_e('Potential Risks', 'site-add-on-watchdog'); ?>
    396             </div>
    397             <div class="wp-watchdog-summary">
    398                 <span class="wp-watchdog-summary__count"><?php echo esc_html(number_format_i18n(count($watchdogRisks))); ?></span>
    399                 <span class="wp-watchdog-summary__label"><?php esc_html_e('items flagged', 'site-add-on-watchdog'); ?></span>
    400             </div>
    401         </div>
    402     <?php if (empty($watchdogRisks)) : ?>
    403         <p class="wp-watchdog-muted"><?php esc_html_e('No risks detected.', 'site-add-on-watchdog'); ?></p>
    404     <?php else : ?>
    405         <?php
    406         $watchdogColumns = [
    407             'plugin'   => __('Plugin', 'site-add-on-watchdog'),
    408             'local'    => __('Local Version', 'site-add-on-watchdog'),
    409             'remote'   => __('Directory Version', 'site-add-on-watchdog'),
    410             'reasons'  => __('Reasons', 'site-add-on-watchdog'),
    411             'actions'  => __('Actions', 'site-add-on-watchdog'),
    412         ];
    413         $watchdogPerPage = (int) apply_filters('site_add_on_watchdog_admin_risks_per_page', 10);
    414         $watchdogNormalizeForSort = static function (string $watchdogValue): string {
    415             $watchdogNormalized = function_exists('remove_accents') ? remove_accents($watchdogValue) : $watchdogValue;
    416 
    417             return strtolower($watchdogNormalized);
    418         };
    419         $watchdogRiskCount = static function (Risk $watchdogRisk): int {
    420             $watchdogCount = count($watchdogRisk->reasons);
    421             $watchdogVulnerabilities = $watchdogRisk->details['vulnerabilities'] ?? [];
    422             if (! is_array($watchdogVulnerabilities)) {
    423                 $watchdogVulnerabilities = [];
    424             }
    425             if ($watchdogVulnerabilities !== []) {
    426                 $watchdogCount += count($watchdogVulnerabilities);
    427             }
    428 
    429             return $watchdogCount;
    430         };
    431         $watchdogVersionScore = static function (string $watchdogVersion): int {
    432             preg_match_all('/\d+/', $watchdogVersion, $watchdogMatches);
    433             $watchdogNumbers = $watchdogMatches[0] ?? [];
    434             if (empty($watchdogNumbers)) {
    435                 return 0;
    436             }
    437 
    438             $watchdogWeights = [100000000, 100000, 100, 1];
    439             $watchdogScore = 0;
    440             foreach ($watchdogWeights as $watchdogIndex => $watchdogWeight) {
    441                 if (! isset($watchdogNumbers[$watchdogIndex])) {
    442                     continue;
    443                 }
    444                 $watchdogScore += ((int) $watchdogNumbers[$watchdogIndex]) * $watchdogWeight;
    445             }
    446 
    447             return $watchdogScore;
    448         };
    449         ?>
    450         <div class="wp-watchdog-risk-table" data-wp-watchdog-table data-per-page="<?php echo esc_attr(max($watchdogPerPage, 1)); ?>">
    451             <div class="wp-watchdog-risk-table__controls">
    452                 <form method="get" class="wp-watchdog-risk-table__filter-form">
    453                     <input type="hidden" name="page" value="site-add-on-watchdog" />
    454                     <label class="wp-watchdog-risk-table__search">
    455                         <span class="screen-reader-text"><?php esc_html_e('Search risks', 'site-add-on-watchdog'); ?></span>
    456                         <input
    457                             type="search"
    458                             name="risk_search"
    459                             value="<?php echo esc_attr($watchdogRiskSearch); ?>"
    460                             placeholder="<?php esc_attr_e('Search plugin or reason', 'site-add-on-watchdog'); ?>"
    461                             data-risk-search
    462                         />
    463                     </label>
    464                     <div class="wp-watchdog-risk-table__sort-select">
    465                         <label class="screen-reader-text" for="wp-watchdog-risk-sort"><?php esc_html_e('Sort risks by', 'site-add-on-watchdog'); ?></label>
    466                         <select id="wp-watchdog-risk-sort" name="risk_sort" data-risk-sort>
    467                             <option
    468                                 value="plugin"
    469                                 data-sort-key="sortPlugin"
    470                                 data-sort-default="asc"
    471                                 <?php selected($watchdogRiskSortSelection, 'plugin'); ?>
    472                             >
    473                                 <?php esc_html_e('Plugin name', 'site-add-on-watchdog'); ?>
    474                             </option>
    475                             <option
    476                                 value="risk_count"
    477                                 data-sort-key="sortRiskCount"
    478                                 data-sort-default="desc"
    479                                 <?php selected($watchdogRiskSortSelection, 'risk_count'); ?>
    480                             >
    481                                 <?php esc_html_e('Risk count', 'site-add-on-watchdog'); ?>
    482                             </option>
    483                             <option
    484                                 value="version_gap"
    485                                 data-sort-key="sortVersionGap"
    486                                 data-sort-default="desc"
    487                                 <?php selected($watchdogRiskSortSelection, 'version_gap'); ?>
    488                             >
    489                                 <?php esc_html_e('Version gap', 'site-add-on-watchdog'); ?>
    490                             </option>
    491                             <option
    492                                 value="local"
    493                                 data-sort-key="sortLocal"
    494                                 data-sort-default="desc"
    495                                 <?php selected($watchdogRiskSortSelection, 'local'); ?>
    496                             >
    497                                 <?php esc_html_e('Local version', 'site-add-on-watchdog'); ?>
    498                             </option>
    499                             <option
    500                                 value="remote"
    501                                 data-sort-key="sortRemote"
    502                                 data-sort-default="desc"
    503                                 <?php selected($watchdogRiskSortSelection, 'remote'); ?>
    504                             >
    505                                 <?php esc_html_e('Directory version', 'site-add-on-watchdog'); ?>
    506                             </option>
    507                             <option
    508                                 value="reasons"
    509                                 data-sort-key="sortReasons"
    510                                 data-sort-default="asc"
    511                                 <?php selected($watchdogRiskSortSelection, 'reasons'); ?>
    512                             >
    513                                 <?php esc_html_e('Reasons', 'site-add-on-watchdog'); ?>
    514                             </option>
    515                         </select>
    516                         <label class="screen-reader-text" for="wp-watchdog-risk-order"><?php esc_html_e('Sort order', 'site-add-on-watchdog'); ?></label>
    517                         <select id="wp-watchdog-risk-order" name="risk_order" data-risk-order>
    518                             <option value="asc" <?php selected($watchdogRiskOrderSelection, 'asc'); ?>>
    519                                 <?php esc_html_e('Ascending', 'site-add-on-watchdog'); ?>
    520                             </option>
    521                             <option value="desc" <?php selected($watchdogRiskOrderSelection, 'desc'); ?>>
    522                                 <?php esc_html_e('Descending', 'site-add-on-watchdog'); ?>
    523                             </option>
    524                         </select>
    525                         <button class="button" type="submit"><?php esc_html_e('Apply', 'site-add-on-watchdog'); ?></button>
    526                     </div>
    527                 </form>
    528                 <div class="wp-watchdog-risk-table__pagination" data-pagination>
    529                     <button type="button" class="button" data-action="prev" aria-label="<?php esc_attr_e('Previous page', 'site-add-on-watchdog'); ?>" disabled>&lsaquo;</button>
    530                     <span class="wp-watchdog-risk-table__page-status" data-page-status></span>
    531                     <button type="button" class="button" data-action="next" aria-label="<?php esc_attr_e('Next page', 'site-add-on-watchdog'); ?>" disabled>&rsaquo;</button>
    532                 </div>
    533             </div>
    534             <div class="wp-watchdog-risk-table__scroll">
    535                 <table class="widefat fixed striped wp-list-table">
    536                     <thead>
    537                     <tr>
    538                         <th scope="col">
    539                             <button type="button" class="wp-watchdog-risk-table__sort" data-sort-key="sortPlugin" data-sort-default="asc" data-sort-initial aria-sort="ascending">
    540                                 <?php echo esc_html($watchdogColumns['plugin']); ?>
    541                                 <span class="wp-watchdog-risk-table__sort-indicator" aria-hidden="true"></span>
    542                             </button>
    543                         </th>
    544                         <th scope="col">
    545                             <button type="button" class="wp-watchdog-risk-table__sort" data-sort-key="sortLocal" data-sort-default="desc" aria-sort="none">
    546                                 <?php echo esc_html($watchdogColumns['local']); ?>
    547                                 <span class="wp-watchdog-risk-table__sort-indicator" aria-hidden="true"></span>
    548                             </button>
    549                         </th>
    550                         <th scope="col">
    551                             <button type="button" class="wp-watchdog-risk-table__sort" data-sort-key="sortRemote" data-sort-default="desc" aria-sort="none">
    552                                 <?php echo esc_html($watchdogColumns['remote']); ?>
    553                                 <span class="wp-watchdog-risk-table__sort-indicator" aria-hidden="true"></span>
    554                             </button>
    555                         </th>
    556                         <th scope="col">
    557                             <button type="button" class="wp-watchdog-risk-table__sort" data-sort-key="sortReasons" data-sort-default="asc" aria-sort="none">
    558                                 <?php echo esc_html($watchdogColumns['reasons']); ?>
    559                                 <span class="wp-watchdog-risk-table__sort-indicator" aria-hidden="true"></span>
    560                             </button>
    561                         </th>
    562                         <th scope="col"><?php echo esc_html($watchdogColumns['actions']); ?></th>
    563                     </tr>
    564                     </thead>
    565                     <tbody>
    566                     <?php foreach ($watchdogRisks as $watchdogRisk) : ?>
    567                         <?php
    568                         $watchdogRemoteVersion = $watchdogRisk->remoteVersion ?? __('N/A', 'site-add-on-watchdog');
    569                         $watchdogRemoteSort    = is_string($watchdogRisk->remoteVersion) ? $watchdogNormalizeForSort($watchdogRisk->remoteVersion) : '';
    570                         $watchdogReasonParts   = $watchdogRisk->reasons;
    571                         $watchdogVulnerabilities = $watchdogRisk->details['vulnerabilities'] ?? [];
    572                         if (! is_array($watchdogVulnerabilities)) {
    573                             $watchdogVulnerabilities = [];
    574                         }
    575                         foreach ($watchdogVulnerabilities as $watchdogVulnerability) {
    576                             if (! empty($watchdogVulnerability['severity_label'])) {
    577                                 $watchdogReasonParts[] = $watchdogVulnerability['severity_label'];
    578                             }
    579                             if (! empty($watchdogVulnerability['title'])) {
    580                                 $watchdogReasonParts[] = $watchdogVulnerability['title'];
    581                             }
    582                             if (! empty($watchdogVulnerability['cve'])) {
    583                                 $watchdogReasonParts[] = $watchdogVulnerability['cve'];
    584                             }
    585                         }
    586                         $watchdogReasonSort = $watchdogNormalizeForSort(implode(' ', $watchdogReasonParts));
    587                         $watchdogRiskCountValue = $watchdogRiskCount($watchdogRisk);
    588                         $watchdogVersionGapValue = abs(
    589                             $watchdogVersionScore($watchdogRemoteVersion) - $watchdogVersionScore($watchdogRisk->localVersion)
    590                         );
    591                         $watchdogFilterText = $watchdogNormalizeForSort(implode(' ', [
    592                             $watchdogRisk->pluginName,
    593                             $watchdogRisk->localVersion,
    594                             $watchdogRemoteVersion,
    595                             implode(' ', $watchdogReasonParts),
    596                         ]));
    597                         ?>
    598                         <tr
    599                             data-sort-plugin="<?php echo esc_attr($watchdogNormalizeForSort($watchdogRisk->pluginName)); ?>"
    600                             data-sort-local="<?php echo esc_attr($watchdogNormalizeForSort($watchdogRisk->localVersion)); ?>"
    601                             data-sort-remote="<?php echo esc_attr($watchdogRemoteSort); ?>"
    602                             data-sort-reasons="<?php echo esc_attr($watchdogReasonSort); ?>"
    603                             data-sort-risk-count="<?php echo esc_attr((string) $watchdogRiskCountValue); ?>"
    604                             data-sort-version-gap="<?php echo esc_attr((string) $watchdogVersionGapValue); ?>"
    605                             data-filter-text="<?php echo esc_attr($watchdogFilterText); ?>"
    606                         >
    607                             <td class="column-primary" data-column="plugin" data-column-label="<?php echo esc_attr($watchdogColumns['plugin']); ?>">
    608                                 <?php echo esc_html($watchdogRisk->pluginName); ?>
    609                             </td>
    610                             <td data-column="local" data-column-label="<?php echo esc_attr($watchdogColumns['local']); ?>">
    611                                 <?php echo esc_html($watchdogRisk->localVersion); ?>
    612                             </td>
    613                             <td data-column="remote" data-column-label="<?php echo esc_attr($watchdogColumns['remote']); ?>">
    614                                 <?php echo esc_html($watchdogRemoteVersion); ?>
    615                             </td>
    616                             <td data-column="reasons" data-column-label="<?php echo esc_attr($watchdogColumns['reasons']); ?>">
    617                                 <ul>
    618                                     <?php foreach ($watchdogRisk->reasons as $watchdogReason) : ?>
    619                                         <li><?php echo esc_html($watchdogReason); ?></li>
    620                                     <?php endforeach; ?>
    621                                     <?php if (! empty($watchdogVulnerabilities)) : ?>
    622                                         <li>
    623                                             <?php esc_html_e('WPScan vulnerabilities:', 'site-add-on-watchdog'); ?>
    624                                             <ul>
    625                                                 <?php foreach ($watchdogVulnerabilities as $watchdogVuln) : ?>
    626                                                     <li>
    627                                                         <?php if (! empty($watchdogVuln['severity']) && ! empty($watchdogVuln['severity_label'])) : ?>
    628                                                             <?php $watchdogSeverityClass = 'wp-watchdog-severity wp-watchdog-severity--' . sanitize_html_class((string) $watchdogVuln['severity']); ?>
    629                                                             <span class="<?php echo esc_attr($watchdogSeverityClass); ?>"><?php echo esc_html($watchdogVuln['severity_label']); ?></span>
    630                                                         <?php endif; ?>
    631                                                         <?php if (! empty($watchdogVuln['title'])) : ?>
    632                                                             <span class="wp-watchdog-vulnerability__title"><?php echo esc_html($watchdogVuln['title']); ?></span>
    633                                                         <?php endif; ?>
    634                                                         <?php if (! empty($watchdogVuln['cve'])) : ?>
    635                                                             <span class="wp-watchdog-vulnerability__cve">- <?php echo esc_html($watchdogVuln['cve']); ?></span>
    636                                                         <?php endif; ?>
    637                                                         <?php if (! empty($watchdogVuln['fixed_in'])) : ?>
    638                                                             <span class="wp-watchdog-vulnerability__fixed">(<?php
    639                                                             printf(
    640                                                                 /* translators: %s: plugin version number. */
    641                                                                 esc_html__('Fixed in %s', 'site-add-on-watchdog'),
    642                                                                 esc_html($watchdogVuln['fixed_in'])
    643                                                             );
    644                                                             ?>)</span>
    645                                                         <?php endif; ?>
    646                                                     </li>
    647                                                 <?php endforeach; ?>
    648                                             </ul>
    649                                         </li>
    650                                     <?php endif; ?>
    651                                 </ul>
    652                             </td>
    653                             <td data-column="actions" data-column-label="<?php echo esc_attr($watchdogColumns['actions']); ?>">
    654                                 <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    655                                     <?php wp_nonce_field($watchdogActionPrefix . '_ignore'); ?>
    656                                     <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_ignore'); ?>">
    657                                     <input type="hidden" name="plugin_slug" value="<?php echo esc_attr($watchdogRisk->pluginSlug); ?>">
    658                                     <button class="button" type="submit"><?php esc_html_e('Ignore', 'site-add-on-watchdog'); ?></button>
    659                                 </form>
    660                             </td>
    661                         </tr>
    662                     <?php endforeach; ?>
    663                     </tbody>
    664                 </table>
    665             </div>
    666         </div>
    667     <?php endif; ?>
    668     </div>
    669 
    670     <div class="wp-watchdog-grid">
    671         <div class="wp-watchdog-surface" id="watchdog-ignored">
    672             <div class="wp-watchdog-section-title">
    673                 <span class="dashicons dashicons-hidden" aria-hidden="true"></span>
    674                 <?php esc_html_e('Ignored Plugins', 'site-add-on-watchdog'); ?>
    675             </div>
    676             <?php if (empty($watchdogIgnored)) : ?>
    677                 <p class="wp-watchdog-muted"><?php esc_html_e('No plugins are being ignored.', 'site-add-on-watchdog'); ?></p>
    678             <?php else : ?>
    679                 <ul class="wp-watchdog-inline-list">
    680                     <?php foreach ($watchdogIgnored as $watchdogIgnoredSlug) : ?>
    681                         <li>
    682                             <form class="wp-watchdog-inline-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    683                                 <?php wp_nonce_field($watchdogActionPrefix . '_unignore'); ?>
    684                                 <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_unignore'); ?>">
    685                                 <input type="hidden" name="plugin_slug" value="<?php echo esc_attr($watchdogIgnoredSlug); ?>">
    686                                 <button class="button" type="submit">
    687                                     <span class="dashicons dashicons-no" aria-hidden="true"></span>
    688                                     <?php echo esc_html($watchdogIgnoredSlug); ?>
    689                                 </button>
    690                             </form>
    691                         </li>
    692                     <?php endforeach; ?>
    693                 </ul>
    694             <?php endif; ?>
    695         </div>
    696         <div class="wp-watchdog-surface wp-watchdog-surface--wide" id="watchdog-settings">
    697             <div class="wp-watchdog-section-title">
    698                 <span class="dashicons dashicons-email-alt" aria-hidden="true"></span>
    699                 <?php esc_html_e('Settings and notifications', 'site-add-on-watchdog'); ?>
    700             </div>
    701     <p class="description"><?php esc_html_e('Save and test buttons save all settings on this form, then send a test to the selected channel.', 'site-add-on-watchdog'); ?></p>
    702     <form class="wp-watchdog-settings-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
    703         <?php wp_nonce_field($watchdogActionPrefix . '_settings'); ?>
    704         <input type="hidden" name="action" value="<?php echo esc_attr($watchdogActionPrefix . '_save_settings'); ?>">
    705         <table class="form-table" role="presentation">
    706             <tr>
    707                 <th scope="row"><?php esc_html_e('History retention', 'site-add-on-watchdog'); ?></th>
    708                 <td>
    709                     <label for="wp-watchdog-history-retention" class="screen-reader-text"><?php esc_html_e('History retention', 'site-add-on-watchdog'); ?></label>
    710                     <input
    711                         type="number"
    712                         id="wp-watchdog-history-retention"
    713                         name="settings[history][retention]"
    714                         value="<?php echo esc_attr($watchdogSettings['history']['retention'] ?? $watchdogHistoryRetention); ?>"
    715                         min="1"
    716                         max="15"
    717                         step="1"
    718                     />
    719                     <p class="description">
    720                         <?php esc_html_e('Number of recent scans to keep available for review and download (maximum 15).', 'site-add-on-watchdog'); ?>
    721                     </p>
    722                 </td>
    723             </tr>
    724             <tr>
    725                 <th scope="row"><?php esc_html_e('Scan frequency', 'site-add-on-watchdog'); ?></th>
    726                 <td>
    727                     <?php
    728                     $watchdogDefaultFrequencyMessage = __('Choose how often the automatic scan should run.', 'site-add-on-watchdog');
    729                     $watchdogTestingFrequencyMessage = sprintf(
    730                         /* translators: 1: interval minutes, 2: duration hours */
    731                         __('Testing mode sends notifications every %1$d minutes to all configured channels and automatically switches back to daily scans after %2$d hours.', 'site-add-on-watchdog'),
    732                         TestingMode::INTERVAL_MINUTES,
    733                         TestingMode::DURATION_HOURS
    734                     );
    735                     $watchdogIsTestingFrequency      = ($watchdogSettings['notifications']['frequency'] ?? '') === 'testing';
    736                     $watchdogTestingExpiresAt        = (int) ($watchdogSettings['notifications']['testing_expires_at'] ?? 0);
    737                     $watchdogNow                     = time();
    738                     $watchdogShowTestingExpiry       = $watchdogIsTestingFrequency && $watchdogTestingExpiresAt > $watchdogNow;
    739                     $watchdogDailyTime               = isset($watchdogSettings['notifications']['daily_time'])
    740                         ? (string) $watchdogSettings['notifications']['daily_time']
    741                         : '08:00';
    742                     $watchdogWeeklyDay = isset($watchdogSettings['notifications']['weekly_day'])
    743                         ? (int) $watchdogSettings['notifications']['weekly_day']
    744                         : 1;
    745                     $watchdogWeeklyTime = isset($watchdogSettings['notifications']['weekly_time'])
    746                         ? (string) $watchdogSettings['notifications']['weekly_time']
    747                         : '08:00';
    748                     ?>
    749                     <label for="wp-watchdog-notification-frequency" class="screen-reader-text"><?php esc_html_e('Scan frequency', 'site-add-on-watchdog'); ?></label>
    750                     <select id="wp-watchdog-notification-frequency" name="settings[notifications][frequency]">
    751                         <option value="daily" <?php selected($watchdogSettings['notifications']['frequency'], 'daily'); ?>><?php esc_html_e('Daily', 'site-add-on-watchdog'); ?></option>
    752                         <option value="weekly" <?php selected($watchdogSettings['notifications']['frequency'], 'weekly'); ?>><?php esc_html_e('Weekly', 'site-add-on-watchdog'); ?></option>
    753                         <option value="testing" <?php selected($watchdogSettings['notifications']['frequency'], 'testing'); ?>><?php echo esc_html(
    754                             sprintf(
    755                                 /* translators: %d: interval minutes */
    756                                 __('Testing (every %d minutes)', 'site-add-on-watchdog'),
    757                                 TestingMode::INTERVAL_MINUTES
    758                             )
    759                         ); ?></option>
    760                         <option value="manual" <?php selected($watchdogSettings['notifications']['frequency'], 'manual'); ?>><?php esc_html_e('Manual (no automatic scans)', 'site-add-on-watchdog'); ?></option>
    761                     </select>
    762                     <?php
    763                     $watchdogFrequencyDescriptionClass = 'description wp-watchdog-frequency-description';
    764                     if ($watchdogIsTestingFrequency) {
    765                         $watchdogFrequencyDescriptionClass .= ' wp-watchdog-frequency-description--testing';
    766                     }
    767                     ?>
    768                     <p
    769                         class="<?php echo esc_attr($watchdogFrequencyDescriptionClass); ?>"
    770                         data-watchdog-frequency-description
    771                         data-default-message="<?php echo esc_attr($watchdogDefaultFrequencyMessage); ?>"
    772                         data-testing-message="<?php echo esc_attr($watchdogTestingFrequencyMessage); ?>"
    773                         aria-live="polite"
    774                     >
    775                         <?php echo esc_html($watchdogIsTestingFrequency ? $watchdogTestingFrequencyMessage : $watchdogDefaultFrequencyMessage); ?>
    776                     </p>
    777                     <div class="wp-watchdog-frequency-options" data-watchdog-frequency-options>
    778                         <div class="wp-watchdog-frequency-options__row" data-watchdog-frequency-target="daily">
    779                             <label for="wp-watchdog-daily-time" class="wp-watchdog-frequency-label">
    780                                 <?php esc_html_e('Daily send time', 'site-add-on-watchdog'); ?>
    781                             </label>
    782                             <input
    783                                 id="wp-watchdog-daily-time"
    784                                 name="settings[notifications][daily_time]"
    785                                 type="time"
    786                                 value="<?php echo esc_attr($watchdogDailyTime); ?>"
    787                                 aria-describedby="wp-watchdog-daily-time-help"
    788                             />
    789                             <p class="description" id="wp-watchdog-daily-time-help">
    790                                 <?php esc_html_e('Time of day to start the daily scan in the WordPress site timezone.', 'site-add-on-watchdog'); ?>
    791                             </p>
    792                         </div>
    793                         <div class="wp-watchdog-frequency-options__row" data-watchdog-frequency-target="weekly">
    794                             <div class="wp-watchdog-frequency-weekly">
    795                                 <label for="wp-watchdog-weekly-day" class="wp-watchdog-frequency-label">
    796                                     <?php esc_html_e('Weekly send day', 'site-add-on-watchdog'); ?>
    797                                 </label>
    798                                 <select id="wp-watchdog-weekly-day" name="settings[notifications][weekly_day]">
    799                                     <option value="1" <?php selected($watchdogWeeklyDay, 1); ?>><?php esc_html_e('Monday', 'site-add-on-watchdog'); ?></option>
    800                                     <option value="2" <?php selected($watchdogWeeklyDay, 2); ?>><?php esc_html_e('Tuesday', 'site-add-on-watchdog'); ?></option>
    801                                     <option value="3" <?php selected($watchdogWeeklyDay, 3); ?>><?php esc_html_e('Wednesday', 'site-add-on-watchdog'); ?></option>
    802                                     <option value="4" <?php selected($watchdogWeeklyDay, 4); ?>><?php esc_html_e('Thursday', 'site-add-on-watchdog'); ?></option>
    803                                     <option value="5" <?php selected($watchdogWeeklyDay, 5); ?>><?php esc_html_e('Friday', 'site-add-on-watchdog'); ?></option>
    804                                     <option value="6" <?php selected($watchdogWeeklyDay, 6); ?>><?php esc_html_e('Saturday', 'site-add-on-watchdog'); ?></option>
    805                                     <option value="7" <?php selected($watchdogWeeklyDay, 7); ?>><?php esc_html_e('Sunday', 'site-add-on-watchdog'); ?></option>
    806                                 </select>
    807                             </div>
    808                             <div class="wp-watchdog-frequency-weekly">
    809                                 <label for="wp-watchdog-weekly-time" class="wp-watchdog-frequency-label">
    810                                     <?php esc_html_e('Weekly send time', 'site-add-on-watchdog'); ?>
    811                                 </label>
    812                                 <input
    813                                     id="wp-watchdog-weekly-time"
    814                                     name="settings[notifications][weekly_time]"
    815                                     type="time"
    816                                     value="<?php echo esc_attr($watchdogWeeklyTime); ?>"
    817                                     aria-describedby="wp-watchdog-weekly-time-help"
    818                                 />
    819                             </div>
    820                             <p class="description" id="wp-watchdog-weekly-time-help">
    821                                 <?php esc_html_e('Day and time to start the weekly scan in the WordPress site timezone.', 'site-add-on-watchdog'); ?>
    822                             </p>
    823                         </div>
    824                     </div>
    825                     <?php if ($watchdogShowTestingExpiry) : ?>
    826                         <?php
    827                         $watchdogTimezone = null;
    828                         if (function_exists('wp_timezone')) {
    829                             $watchdogTimezone = wp_timezone();
    830                         } else {
    831                             $watchdogTimezoneString = (string) get_option('timezone_string');
    832                             if ($watchdogTimezoneString !== '') {
    833                                 try {
    834                                     $watchdogTimezone = new DateTimeZone($watchdogTimezoneString);
    835                                 } catch (Exception $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
    836                                 }
    837                             }
    838 
    839                             if (! $watchdogTimezone) {
    840                                 $watchdogGmtOffset = get_option('gmt_offset');
    841                                 if (is_numeric($watchdogGmtOffset)) {
    842                                     $watchdogSecondsInHour = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;
    843                                     $watchdogSecondsOffset = (int) round((float) $watchdogGmtOffset * $watchdogSecondsInHour);
    844                                     $watchdogTimezoneName  = timezone_name_from_abbr('', $watchdogSecondsOffset, 0);
    845                                     if ($watchdogTimezoneName !== false) {
    846                                         try {
    847                                             $watchdogTimezone = new DateTimeZone($watchdogTimezoneName);
    848                                         } catch (Exception $exception) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
    849                                         }
    850                                     }
    851                                 }
    852                             }
    853                         }
    854 
    855                         if (! $watchdogTimezone && class_exists('DateTimeZone')) {
    856                             $watchdogTimezone = new DateTimeZone('UTC');
    857                         }
    858 
    859                         $watchdogTestingExpiryMessage = sprintf(
    860                             /* translators: 1: datetime, 2: relative time */
    861                             __('Testing mode will automatically switch back to daily scans on %1$s (%2$s remaining).', 'site-add-on-watchdog'),
    862                             wp_date(
    863                                 get_option('date_format') . ' ' . get_option('time_format'),
    864                                 $watchdogTestingExpiresAt,
    865                                 $watchdogTimezone
    866                             ),
    867                             human_time_diff($watchdogNow, $watchdogTestingExpiresAt)
    868                         );
    869                         ?>
    870                         <p class="description wp-watchdog-frequency-description wp-watchdog-frequency-description--expires">
    871                             <?php echo esc_html($watchdogTestingExpiryMessage); ?>
    872                         </p>
    873                     <?php endif; ?>
    874                 </td>
    875             </tr>
    876             <tr>
    877                 <th scope="row"><?php esc_html_e('Email notifications', 'site-add-on-watchdog'); ?></th>
    878                 <td data-watchdog-notification>
    879                     <label class="wp-watchdog-notification-toggle">
    880                         <input type="checkbox" name="settings[notifications][email][enabled]" <?php checked($watchdogSettings['notifications']['email']['enabled']); ?> data-watchdog-toggle />
    881                         <?php esc_html_e('Enabled', 'site-add-on-watchdog'); ?>
    882                     </label>
    883                     <div class="wp-watchdog-notification-fields" data-watchdog-fields>
    884                         <label>
    885                             <?php esc_html_e('Recipients (comma separated)', 'site-add-on-watchdog'); ?><br />
    886                             <input type="text" name="settings[notifications][email][recipients]" value="<?php echo esc_attr($watchdogSettings['notifications']['email']['recipients']); ?>" class="regular-text" />
    887                         </label>
    888                         <p class="description"><?php esc_html_e('Separate addresses with commas, semicolons, or spaces. WordPress administrator accounts are always included.', 'site-add-on-watchdog'); ?></p>
    889                         <p class="description"><?php esc_html_e('Email uses WordPress mail delivery. Your host must support sending mail, or you must configure an SMTP/mail plugin. Discord and other webhooks do not use this mail configuration.', 'site-add-on-watchdog'); ?></p>
    890                         <p><button class="button" type="submit" name="test_channel" value="email"><?php esc_html_e('Save and test email', 'site-add-on-watchdog'); ?></button></p>
    891                     </div>
    892                 </td>
    893             </tr>
    894             <tr>
    895                 <th scope="row"><?php esc_html_e('Discord notifications', 'site-add-on-watchdog'); ?></th>
    896                 <td data-watchdog-notification>
    897                     <label class="wp-watchdog-notification-toggle">
    898                         <input type="checkbox" name="settings[notifications][discord][enabled]" <?php checked($watchdogSettings['notifications']['discord']['enabled']); ?> data-watchdog-toggle />
    899                         <?php esc_html_e('Enabled', 'site-add-on-watchdog'); ?>
    900                     </label>
    901                     <div class="wp-watchdog-notification-fields" data-watchdog-fields>
    902                         <label>
    903                             <?php esc_html_e('Discord webhook URL', 'site-add-on-watchdog'); ?><br />
    904                             <input type="url" name="settings[notifications][discord][webhook]" value="<?php echo esc_attr($watchdogSettings['notifications']['discord']['webhook']); ?>" class="regular-text" />
    905                         </label>
    906                         <p><button class="button" type="submit" name="test_channel" value="discord"><?php esc_html_e('Save and test Discord', 'site-add-on-watchdog'); ?></button></p>
    907                     </div>
    908                 </td>
    909             </tr>
    910             <tr>
    911                 <th scope="row"><?php esc_html_e('Slack notifications', 'site-add-on-watchdog'); ?></th>
    912                 <td data-watchdog-notification>
    913                     <label class="wp-watchdog-notification-toggle">
    914                         <input type="checkbox" name="settings[notifications][slack][enabled]" <?php checked($watchdogSettings['notifications']['slack']['enabled']); ?> data-watchdog-toggle />
    915                         <?php esc_html_e('Enabled', 'site-add-on-watchdog'); ?>
    916                     </label>
    917                     <div class="wp-watchdog-notification-fields" data-watchdog-fields>
    918                         <label>
    919                             <?php esc_html_e('Slack webhook URL', 'site-add-on-watchdog'); ?><br />
    920                             <input type="url" name="settings[notifications][slack][webhook]" value="<?php echo esc_attr($watchdogSettings['notifications']['slack']['webhook']); ?>" class="regular-text" />
    921                         </label>
    922                         <p><button class="button" type="submit" name="test_channel" value="slack"><?php esc_html_e('Save and test Slack', 'site-add-on-watchdog'); ?></button></p>
    923                     </div>
    924                 </td>
    925             </tr>
    926             <tr>
    927                 <th scope="row"><?php esc_html_e('Microsoft Teams notifications', 'site-add-on-watchdog'); ?></th>
    928                 <td data-watchdog-notification>
    929                     <label class="wp-watchdog-notification-toggle">
    930                         <input type="checkbox" name="settings[notifications][teams][enabled]" <?php checked($watchdogSettings['notifications']['teams']['enabled']); ?> data-watchdog-toggle />
    931                         <?php esc_html_e('Enabled', 'site-add-on-watchdog'); ?>
    932                     </label>
    933                     <div class="wp-watchdog-notification-fields" data-watchdog-fields>
    934                         <label>
    935                             <?php esc_html_e('Teams webhook URL', 'site-add-on-watchdog'); ?><br />
    936                             <input type="url" name="settings[notifications][teams][webhook]" value="<?php echo esc_attr($watchdogSettings['notifications']['teams']['webhook']); ?>" class="regular-text" />
    937                         </label>
    938                         <p class="description"><?php esc_html_e('Supports Microsoft Teams Workflows and existing Incoming Webhook connectors.', 'site-add-on-watchdog'); ?></p>
    939                         <p><button class="button" type="submit" name="test_channel" value="teams"><?php esc_html_e('Save and test Teams', 'site-add-on-watchdog'); ?></button></p>
    940                     </div>
    941                 </td>
    942             </tr>
    943             <tr>
    944                 <th scope="row"><?php esc_html_e('Generic webhook', 'site-add-on-watchdog'); ?></th>
    945                 <td data-watchdog-notification>
    946                     <label class="wp-watchdog-notification-toggle">
    947                         <input type="checkbox" name="settings[notifications][webhook][enabled]" <?php checked($watchdogSettings['notifications']['webhook']['enabled']); ?> data-watchdog-toggle />
    948                         <?php esc_html_e('Enabled', 'site-add-on-watchdog'); ?>
    949                     </label>
    950                     <div class="wp-watchdog-notification-fields" data-watchdog-fields>
    951                         <label>
    952                             <?php esc_html_e('Webhook URL', 'site-add-on-watchdog'); ?><br />
    953                             <input type="url" name="settings[notifications][webhook][url]" value="<?php echo esc_attr($watchdogSettings['notifications']['webhook']['url']); ?>" class="regular-text" />
    954                         </label>
    955                         <p>
    956                             <label>
    957                                 <?php esc_html_e('Webhook secret (optional)', 'site-add-on-watchdog'); ?><br />
    958                                 <input type="password" name="settings[notifications][webhook][secret]" value="<?php echo esc_attr($watchdogSettings['notifications']['webhook']['secret'] ?? ''); ?>" class="regular-text" autocomplete="new-password" />
    959                             </label>
    960                             <span class="description"><?php esc_html_e('Signs the exact JSON body with HMAC-SHA256 in the X-Watchdog-Signature header. Test messages are marked in the payload.', 'site-add-on-watchdog'); ?></span>
    961                         </p>
    962                         <p><button class="button" type="submit" name="test_channel" value="webhook"><?php esc_html_e('Save and test webhook', 'site-add-on-watchdog'); ?></button></p>
    963                     </div>
    964                 </td>
    965             </tr>
    966             <tr>
    967                 <th scope="row"><?php esc_html_e('WPScan API key', 'site-add-on-watchdog'); ?></th>
    968                 <td>
    969                     <input type="password" name="settings[notifications][wpscan_api_key]" value="<?php echo esc_attr($watchdogSettings['notifications']['wpscan_api_key']); ?>" class="regular-text" autocomplete="new-password" />
    970                     <p class="description"><?php esc_html_e('Optional. Provide your own WPScan API key to enrich vulnerability reports.', 'site-add-on-watchdog'); ?></p>
    971                 </td>
    972             </tr>
    973         </table>
    974         <p><button class="button button-primary" type="submit"><?php esc_html_e('Save settings', 'site-add-on-watchdog'); ?></button></p>
    975     </form>
    976         </div>
    977     </div>
     948    </section>
    978949</div>
  • site-add-on-watchdog/trunk/templates/history.php

    r3690952 r3799692  
    55 * @var array $watchdogHistoryRecords
    66 * @var array $watchdogHistoryDownloads
    7  * @var int   $historyRetention
    8  * @var int   $historyDisplay
    9  * @var array $historyRecords
    10  * @var array $historyDownloads
    117 */
    128defined('ABSPATH') || exit;
    139
    14 $watchdogHistoryRetention = $watchdogHistoryRetention ?? $historyRetention ?? 0;
    15 $watchdogHistoryDisplay   = $watchdogHistoryDisplay ?? $historyDisplay ?? 0;
    16 $watchdogHistoryRecords   = $watchdogHistoryRecords ?? $historyRecords ?? [];
    17 $watchdogHistoryDownloads = $watchdogHistoryDownloads ?? $historyDownloads ?? [];
     10$watchdogHistoryFormat = sprintf('%s %s', get_option('date_format', 'Y-m-d'), get_option('time_format', 'H:i'));
    1811?>
    1912
    2013<?php if (empty($watchdogHistoryRecords)) : ?>
    21     <p><?php echo esc_html__('No scans have been recorded yet. Run a scan to populate your history.', 'site-add-on-watchdog'); ?></p>
     14    <p class="wp-watchdog-card__description"><?php esc_html_e('No scans have been recorded yet. Run a scan to populate your history.', 'site-add-on-watchdog'); ?></p>
    2215<?php else : ?>
    23     <p class="description">
     16    <p class="wp-watchdog-card__description">
    2417        <?php
    25         $watchdogDisplayCount = esc_html(number_format_i18n($watchdogHistoryDisplay));
    26         $watchdogRetentionCount = esc_html(number_format_i18n($watchdogHistoryRetention));
    27         echo esc_html(
    28             sprintf(
    29                 /* translators: 1: number of scans shown, 2: total scans retained */
    30                 esc_html__('Showing the last %1$s scans (retaining %2$s in total).', 'site-add-on-watchdog'),
    31                 $watchdogDisplayCount,
    32                 $watchdogRetentionCount
    33             )
     18        printf(
     19            /* translators: 1: number of scans shown, 2: total scans retained */
     20            esc_html__('Showing the last %1$s scans (retaining %2$s in total).', 'site-add-on-watchdog'),
     21            esc_html(number_format_i18n($watchdogHistoryDisplay)),
     22            esc_html(number_format_i18n($watchdogHistoryRetention))
    3423        );
    3524        ?>
    3625    </p>
    37     <div class="wp-watchdog-history-grid" role="list">
     26    <ul class="wp-watchdog-history">
    3827        <?php foreach ($watchdogHistoryRecords as $watchdogRecord) : ?>
    39             <?php $watchdogDownloads = $watchdogHistoryDownloads[$watchdogRecord['run_at']] ?? []; ?>
    40             <?php $watchdogHasRisks  = (int) $watchdogRecord['risk_count'] > 0; ?>
    41             <div class="wp-watchdog-history-card" role="listitem">
    42                 <div class="wp-watchdog-history-card__header">
    43                     <h3 class="wp-watchdog-history-card__title">
    44                         <span class="dashicons dashicons-backup" aria-hidden="true"></span>
    45                         <?php
    46                         $watchdogFormat = sprintf('%s %s', get_option('date_format', 'Y-m-d'), get_option('time_format', 'H:i'));
    47                         echo esc_html(wp_date($watchdogFormat, $watchdogRecord['run_at']));
    48                         ?>
    49                     </h3>
    50                     <span class="wp-watchdog-history-badge <?php echo $watchdogHasRisks ? 'wp-watchdog-history-badge--risk' : 'wp-watchdog-history-badge--safe'; ?>">
    51                         <span class="dashicons <?php echo $watchdogHasRisks ? 'dashicons-warning' : 'dashicons-yes-alt'; ?>" aria-hidden="true"></span>
    52                         <?php
    53                         $watchdogRiskLabel = function_exists('_n')
    54                             ? /* translators: %s: number of risks. */
    55                             _n(
    56                                 '%s risk',
    57                                 '%s risks',
    58                                 $watchdogRecord['risk_count'],
    59                                 'site-add-on-watchdog'
    60                             )
    61                             : ($watchdogRecord['risk_count'] === 1 ? '%s risk' : '%s risks');
    62 
    63                         $watchdogRiskCount = number_format_i18n($watchdogRecord['risk_count']);
    64                         printf(esc_html($watchdogRiskLabel), esc_html($watchdogRiskCount));
    65                         ?>
    66                     </span>
    67                 </div>
    68                 <p class="wp-watchdog-history-card__meta">
    69                     <?php echo esc_html__('Download the report for this run:', 'site-add-on-watchdog'); ?>
    70                 </p>
    71                 <div class="wp-watchdog-history-card__downloads">
     28            <?php
     29            $watchdogDownloads = $watchdogHistoryDownloads[$watchdogRecord['run_at']] ?? [];
     30            $watchdogRecordRiskCount = (int) $watchdogRecord['risk_count'];
     31            ?>
     32            <li class="wp-watchdog-history__item">
     33                <span class="wp-watchdog-history__date">
     34                    <span class="dashicons dashicons-backup" aria-hidden="true"></span>
     35                    <?php echo esc_html(wp_date($watchdogHistoryFormat, $watchdogRecord['run_at'])); ?>
     36                </span>
     37                <span class="wp-watchdog-badge <?php echo $watchdogRecordRiskCount > 0 ? 'wp-watchdog-badge--warn' : 'wp-watchdog-badge--ok'; ?>">
     38                    <?php
     39                    printf(
     40                        /* translators: %s: number of risks. */
     41                        esc_html(_n('%s risk', '%s risks', $watchdogRecordRiskCount, 'site-add-on-watchdog')),
     42                        esc_html(number_format_i18n($watchdogRecordRiskCount))
     43                    );
     44                    ?>
     45                </span>
     46                <span class="wp-watchdog-history__downloads">
    7247                    <?php if (! empty($watchdogDownloads['json'])) : ?>
    7348                        <a class="button button-small" href="<?php echo esc_url($watchdogDownloads['json']); ?>"><?php esc_html_e('Download JSON', 'site-add-on-watchdog'); ?></a>
    … …  
    7651                        <a class="button button-small" href="<?php echo esc_url($watchdogDownloads['csv']); ?>"><?php esc_html_e('Download CSV', 'site-add-on-watchdog'); ?></a>
    7752                    <?php endif; ?>
    78                 </div>
    79             </div>
     53                </span>
     54            </li>
    8055        <?php endforeach; ?>
    81     </div>
     56    </ul>
    8257<?php endif; ?>
Note: See TracChangeset for help on using the changeset viewer.