Repository navigation
Client reports: verified accuracy fixes, Clients tab, and fleet fixes for replacing ManageWP - #63
Merged
Merged
Conversation
…engths, explicit From
Branding: the compiler fell back to config('app.url') for the stored
support_url whenever both the reports and the shared Companion support URLs
were blank. Control runs on a private host, so every report carried a
localhost address that would leak the moment any view rendered it. The
support URL now falls back to the agency website and then null. The dead
non-branding-manager branch (and its unused Settings dependency) is gone.
The branding manager itself is unchanged: a blank Companion support URL
still hides Companion's support button.
Settings: the reports support URL and the shared support / agency website
URLs must now be public http(s) addresses. Localhost, loopback, private and
reserved IPs, single-label and internal-only names, and the app's own URL
are rejected; blank is still accepted. The reports tab help text states the
real fallback order.
Metadata: Carbon 3's diffInDays returns a float, and the period end is the
last microsecond of the day, so meta.period_days was stored as 31.99... for
a 31-day month and uptime total minutes came out one short. Both are now
whole numbers (31 / 44,640 for a 31-day month, 30 / 43,200 for 30 days).
SCHEMA_VERSION is bumped to 4.
Email: the envelope now sets From to the configured sending address under
the report brand's company name, so the inbox no longer depends on
MAIL_FROM_NAME (by default the internal product name). The operator's
"Email version" page shows that From line. The plain-text part now prints
the footer text when one is set, matching the HTML part.
Wording: template section descriptions promised data the report never
contains (referrers, page views, TTFB, translation upgrades, restore
readiness, form deliverability). They now describe what is compiled. The
contact-form testing docs no longer claim inbox delivery verification:
scheduled tests run in lab mode with the email suppressed, and live runs
only record whether the site reported the email as sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The scheduled form-test run starts at a fixed time each day, but each test's last_test_at is stamped when the test finishes, a few minutes later and at a slightly different minute every day. The due filter used a strict 24-hour / 7-day cutoff, so whenever today's run reached the filter earlier in the day than the previous test had finished, the row was treated as not yet due and skipped. Daily tests ended up running roughly every other day and weekly tests every eighth day, which also under-counted the checks shown in monthly client reports. Daily rows are now due once last_test_at is more than 20 hours old and weekly rows once it is more than 7 days minus 4 hours old. The margins absorb the run-time drift without letting a row run twice in the same daily window or turning weekly into a six-day cadence. Adds a regression test frozen just after the scheduled start time with the previous runs stamped a few minutes later in the day, and updates the cadence step in the contact-form testing docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ut zero-filling history The Pressable traffic job ran at 06:37 UTC, after the 06:00 client-report run, so a month's report compiled from a last-day row holding only the first hours of that day. It now runs at 04:45 UTC: Pressable's buckets are UTC days, so the previous day is complete by then, and the ~15-20 minute run ends well before 06:00. A test pins that ordering. The command also upserted the zero-filled 30-day Companion payload into site_traffic_daily, so any day the API had no bucket for (for example before a site moved to Pressable) overwrote earlier history, including the previous host's access-log rows, with zeros. It now writes only the days, and per day only the metric family (requests or uniques), that Pressable actually returned. Empty buckets count as no data. Only the last 28 days are written, because the API's "Past 1 month" window can begin part-way through a day as recent as 28 days ago. The Companion payload stays zero-filled over 30 days, as its chart expects. The history write now happens before, and independently of, the Companion capability check and push, and covers every Pressable site with a Pressable site id rather than only Companion-equipped ones. A Companion outage or a site without Companion no longer leaves its client report without traffic. Top paths are fetched only for sites that get the push. Docs updated for the new time and behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A site behind a host rate limiter or a security plugin's anti-bot challenge can answer the uptime monitor with 429 Too Many Requests while real visitors load it fine. The prober treated 429 as a plain failure, so two in a row moved the site to down and a long block showed up as a multi-day outage in uptime figures and client reports. - UptimeProber returns UptimeProbeResult::blocked() for 429 (not a success, not a plain failure; Retry-After kept, capped). - UptimeStateUpdater routes blocked probes to a new probe_blocked state: the failure streak is not incremented, last-up is not set, and the site never goes down on a 429. Entering the state records a probe_blocked site_uptime_events row and an action log entry, so stats and the event feeds never count it as downtime. A down site that starts answering 429 has its outage closed (recovery is not claimed). A real outage while blocked still goes down after the usual threshold; a success, maintenance or a down transition ends the blocked stretch. - After N hours blocked (Monitoring -> Settings, default 6) one uptime_probe_blocked ops chat notice is sent per stretch through ChatNotifier. It honours the per-site ignore flag, is skipped for inactive sites by the dispatcher, can be switched off per channel like other events, and never reaches client Slack or SMS. - New sites columns uptime_probe_blocked_since and uptime_probe_blocked_alerted_at (migration). - Monitoring board, site widget, sites list and recheck endpoint show the blocked state instead of down/unknown; docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… once per local date
Vulnerable-first ordering never matched. The nightly runner looked up
each Companion slug ("dir/file.php") in a map keyed by the vulnerability
matcher's directory-only slug, so is_vulnerable was false for every
plugin in a folder. It now compares the directory part, and it keeps the
highest patched_in per directory for the pause logic below.
Auto-failure pauses never expired. Once a plugin failed N nightly runs it
was dropped before any vulnerability check, so later releases, including
security fixes, were never attempted. filterIgnored still honours manual
ignores, but lets an auto_failure pause through when:
- the offered version differs from the streak's last_target_version
(one attempt per new release, because a failed attempt records it as
the new last target), or
- the installed version is vulnerable and the offered version is at or
above the highest patched_in of the matching advisories.
A successful update that reaches its target version now deletes the
slug's auto_failure ignore, clears the streak's pause markers, and
re-pushes the exceptions list so the wp-admin "paused" notice clears.
The chained 01:30 America/New_York nightly fired twice on the DST
fall-back night, so failures were counted twice in one day. The command
now skips (with a log line) when a fleet-wide nightly batch was already
queued during the current local date, judged by created_at so 23/25-hour
days are handled. --force overrides. --site runs are not guarded and get
a "nightly-<date>-site-" batch id, so they never block the fleet run.
Batch ids now carry the local date (the same as before for the
scheduled run).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…se site on fs errors Companion and Renegade builds in the field return ok=true and upgrade_completed=true even when the WordPress upgrader logged an "Error: ..." line (could not copy file, could not create directory, download unauthorized, bad zip). AbstractRunUpdate treated every such stalled no-op as "WordPress's own update-checker no longer offered this update (premium/licensed)", hiding the real cause from the job error, the failure streak, the auto-ignore and the ops chat alert, and still ran post-update verify/repair. Inside the stalled no-op branch the job now looks for the last "Error:" line in the upgrader output. When there is one, the error records that line plus the line before it (never a download line), upgrade_completed is set to false so verify/repair is skipped, and a reason_code is stored (fs_permission, download_unauthorized, bad_package, no_package, upgrader_error). Without an Error: line the old explanation is kept with reason_code not_offered. When Companion reports ok=false itself, its error text is kept and the reason code is still derived from the output. The action-log summary now follows the job's verdict, so a failed upgrade is no longer logged as "Plugin updated: 1.0 -> 1.0". reason_code is a new nullable column on plugin_update_jobs and plugin_update_failure_streaks. The upgrader's "Downloading update from <url>" lines carry licence keys, vendor tokens and signed download URLs. A new UpgraderOutput helper redacts every update result before it is stored or sent (job messages and error, action_log details/summary/error and their Companion activity mirror, streaks, ignores, chat): download URLs are cut to scheme://host/[redacted], other URLs lose credentials, query string and fragment, and JWTs, bearer tokens and secret-named key=value pairs are masked. The single-plugin update endpoint applies the same redaction. A file-permission failure is a site problem, not a plugin one. When an fs_permission streak reaches the threshold and no plugin or theme update on the site has succeeded since the streak began, the recorder now sets the site's existing auto_updates_paused flag once (with a "Filesystem not writable" reason), writes one auto_updates_toggled action log and sends one ops alert, instead of auto-ignoring each plugin and pushing client-visible pause notices. An already-paused site is left alone. Other reason codes keep the per-plugin auto-ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…p stale hashes
A site whose Companion build predates the backup-create capability could
have off-site relay switched on (enrolment turned it on for every
standalone site), and then failed every night with an error nobody could
see: the direct-only pass only logs and chats its failures, and its output
is not stored.
- CompanionBackupRelayAdapter refuses the direct upload before calling the
plugin when backup-create is missing, with an actionable message
("Companion <version> lacks the backup-create capability; update
Companion or switch to Renegade"). The run counts the site as failed
instead of skipping it silently.
- Relay can no longer be switched on for such a site: the per-site toggle
returns 422 / a warning, the settings single toggle refuses it, the bulk
form enables the other sites and lists the refused ones, and enrolment
only turns relay on when /health lists backup-create. A site that is
already on stays on so its failures stay visible.
- New sites columns backup_relay_last_attempt_at, backup_relay_last_error
and backup_relay_failing_since. ArchiveSiteBackupJob records every
failure (also for queued runs), RunBackupRelayNow records failures in its
per-site catch, and a successful archive clears them. The site Backups
card shows the error and the start of the failure streak, and Backup Now
stops polling as soon as a failure is recorded.
- markArchived now sets backup_relay_last_sha256 to null when an archive is
recorded without a hash (an object adopted after a timeout or gateway
error), so a previous archive's hash is never paired with a newer key
and one-click restore cannot fail the checksum on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ds PHP The Companion malware probe could miss things, alert too often, or not alert at all: - Hosts report finding paths in different shapes: relative, absolute from a managed host's docroot, or with Windows separators. Suppression lists and the own-scanner filter compared exact strings, so an entry stored one way never matched a finding reported another way. Add CompanionMalwareScanner::normalizeFindingPath(): backslashes become '/', and a known docroot prefix is stripped (the site's wp_path, an ABSPATH in the Companion snapshot, /srv/htdocs/ and /wordpress/core/). Only whole known prefixes are stripped, so root-level files normalize too and paths outside every known root stay absolute. Findings are normalized before dedup and the own-scanner filter, and stored suppression entries are normalized at compare time so existing relative and absolute entries both keep matching. - Suppression is now per kind + path. clockwork:suppress-malware-path takes --kind (php_in_uploads by default, or preg_replace_e_modifier for a reviewed legacy vendor file), stores the normalized path, and refuses the positive signature kinds. php_in_uploads keeps its original state key. - The SSH grep now also excludes clockwork-renegade, which ships the same scanner source as clockwork-companion. - Companion scans never set has_malware_hit, so the clean-to-hit chat alert never fired for them. buildResult now sets it when any finding kind is outside WARNING_KINDS. The recorder compares against the latest non-failed scan of the type, so a failed scan in between no longer counts as "clean" and re-alerts. - php_in_uploads is review-tier and never reached chat or /issues, so a newly dropped PHP file could sit unnoticed. The recorder now posts a new phpInUploadsDetected chat notice (own toggle, default on, not gated on is_inactive) for uploads paths missing from the previous non-failed scan, compared normalized. Each file alerts once, on the scan where it first appears. File names are defanged before posting. - The SSH transport hard-coded scan_aborted=false even though it cuts each check off with head. Each check now asks for one line past its cap. A check that goes over sets scan_aborted, abort_reason=ssh_result_limit and details.truncated_checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…invoices to sites Invoice lines and Site rows were compared as raw strings. The extractor dropped a leading "www." from invoice text, but both syncs then looked the domain up exactly, so a site stored as "www.example.com" never linked to its Bill.com customer and never got a care-plan domain hit. The extractor also only accepted a fixed list of eight TLDs, so fleet sites on other TLDs (.tv, .law, .media, .art) could never be recognised in an invoice. - Add App\Support\DomainNormalizer: lowercase, strip scheme, path, query, port, trailing dot and a single leading "www.". - Add Modules\BillCom\SiteDomainIndex, built once per sync: non-archived sites keyed by normalized domain, archived-site keys, and the TLDs the non-archived fleet uses. When two non-archived sites share a key, the exact-match site wins (then lowest id) and the key is reported in a new domain_collisions stat instead of being picked silently. - InvoiceDomainExtractor normalizes every match and accepts extra TLDs passed in by the caller (only well-formed alphabetic TLDs are used). - CustomerSyncService and CarePlanSyncService use the index for lookups and pass the fleet TLDs to the extractor. - Domains that only belong to an archived site are no longer reported as unmatched; they are listed separately (archived_site_domains / archived_care_plan_domains) and labelled "(archived)" in console output. - Document the matching rules in the Bill.com integration docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… text and preview
The HTML email, the plain-text email and the preview page each decided on
their own which sections to show, and they had drifted. The preview (which
is what prints as the PDF and is also the public report page) showed an
empty "0.00 hours" work log the email hides, printed backups the email
leaves out, listed only plugin updates under a count that included themes
and core (so a theme-only month read "1 update" then "No updates were
applied"), and had no contact-forms section at all.
Visibility: a new Support\ReportSections decides, from the stored report,
which sections the client sees, and all three views use it. On the
operator preview a hidden section shows as a short no-print note saying
why ("Work log hidden from the client: no hours were logged this period").
Preview: the updates table and its tile and heading counts come from
ClientReportMail::updateRows() (core, themes and plugins), keeping the
existing empty-state wording. It gains a Contact forms section with the
email's wording, and the forms tile covers lab checks ("18/19"). Operator
details (staff names on work-log rows, the backup check error, "no blocker
reports to Control", the non-WordPress core-files card, records-start note)
appear only on the operator preview and never print or reach the public
view. The uptime blurb uses the email's wording.
Wording (Support\ReportPhrases, shared by all three views):
- Outage start times are shown in the report timezone instead of raw UTC,
from a new config value clockwork.reports.timezone
(CLOCKWORK_REPORTS_TIMEZONE, default America/New_York), with the zone
abbreviation. An outage already open when the period began shows only
the date, since its stored start is the period start.
- "Known plugin vulnerabilities (as of <date>)" now reads "(checked <date>)",
since the date is when the plugin list was checked, and the affected
plugins are named when any are found.
- The backup destination is stored and rendered as a whole phrase ("by your
host, Pressable", "to independent off-site storage (Amazon S3)"), so the
templates no longer prepend a dangling "by". Reports compiled earlier,
which stored only the place, still read correctly.
Older stored reports keep rendering: every new read has a default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…n, and keep rate-limited probes out of downtime A site enrolled part-way through a month was reported as monitored for the whole month: uptime divided its downtime by every minute of the period, so a few days of watching read as a month of near-perfect availability, and nothing told the reader or the reviewer. The compiler now works out when monitoring began: the earliest uptime seed transition (written by the first successful probe), or, when there is none or it was backfilled (a backfilled seed is dated when the backfill ran), the site's creation time if that falls inside the period. Starting on the period's first day still counts as the whole period, as it does for backups. When monitoring began later: - uptime is measured from then (denominator and interval clamp); - uptime.monitored_since and meta.coverage_start record it; - both emails and the preview say "Monitoring began <date>, so this report covers <date> – <end>", and the uptime section notes it; - the report is held for review with a reason saying it covers only part of the month; - monitoring that began only after the period hides the uptime section and says why. A host that rate-limits the monitor answers HTTP 429, which the probe recorded as the site going down. For data already recorded, an interval opened by a 429 down event is now kept out of downtime and the outage list and reported internally as uptime.probe_blocked_minutes (shown only on the operator preview). When the only interval open at the end of the period is a 429, the review reason now says the monitor was blocked rather than that the site was still down. A real outage still open at the end keeps the existing "still down" reason. ReportPhrases::timezone() now also falls back to UTC when there is no app config, so the review flags stay plain functions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…core-file and blocked rows, slug-joined vulnerability holds Malware: the security section copied the newest malware scan's stored status, so a scan that stopped at its file or time limit, or a clean SSH scan (whose output is capped and which can't count the files it covered), read "No malware found", and malware found earlier in the month vanished once a later scan came back clean. A clean result from a partial scan is now "incomplete", shown as "Partial scan" in both emails and the preview and held for review; a partial scan that flagged something still counts as flagged. The compiler records the worst result of the period's Companion scans, how many days found malware and when last, and holds the report when an earlier scan found malware the latest one doesn't. SiteCheck fallbacks and rows without recorded details keep their stored status. The review reason for a flagged scan now names the finding kinds with counts and the scan id; only kinds are stored, never file paths. Core files: on hosts with no way to run the core checksum check (no server SSH, no Pressable API, no command runner), "Not checked this month" was true every month. The compiler now records checksum_supported, mirroring the verifier's transport choice. When false, both emails leave the row out and only the operator preview says "Not checked (no checksum path for this host)". When supported but no check ran, the report is held. Reports without the key render as before. Threats blocked: a capability string or an active login limiter was enough to show "0 blocked", even when none of the site's lockouts ever reached Control. The count is now shown only with evidence: bans in the period, review-queue rows for the site in the period, or a blocker on a server with a provisioned fail2ban jail whose lockouts were pulled since the period began. "At the server" is said only where such a jail exists; elsewhere the copy says the site blocks malicious visitors. Vulnerable plugins: the hold matched vulnerable and waiting plugins by display name, which repeat (backup copies, same-named plugins) and say nothing about whether the waiting version fixes anything. The compiler now stores each vulnerable plugin by file slug with the version that fixes it, the update offered and any update pause read at compile time, plus the waiting plugins by slug. The report is held when a waiting update reaches the fixed version, or when a vulnerable plugin's updates are paused, naming the plugin, the fix and the pause. Older reports keep the name match. SCHEMA_VERSION is now 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…affic and failed form checks; log care-plan skips Backups: a source can list a backup every day that holds next to nothing. compileBackups now compares the newest database (db or full) backup in the period with the database size Companion reports. Under about 1% of it (or under 100 KB of a database over 10 MB), the status is 'suspect': the section is hidden from the client like the other unverified statuses, and the review flags give both sizes. A verified month from a daily source (Pressable, the S3 relay, or SpinupWP when it has back-to-back days) that covers the period but has more than three days without a backup is also held, saying "Backed up on only N of M days this month". The client wording is unchanged and never promises daily backups. Traffic: compileTraffic records days_covered, an integer period_days, the source (pressable or nginx), the two busiest IPs' share of requests, and a status. 'partial' when more than one day is missing, or the last day's row is missing or was written before that day was over (a host feed that fills in late). 'suspect' when two IPs carry over 90% of an access-log source's requests (a proxy or gateway in front of the site), or a busy site has almost no visits. 'no_data' for a host we collect from with nothing for the period, 'unsupported' for one we don't. Zero rows count as no data. Only 'ok' is shown to the client; reports compiled before the status existed render as before. Partial, suspect and no_data hold the report with the reason; the preview tells the operator the coverage and source. The period query is half-open so a last day stored with a time part still matches. Forms: lab_accepted counts runs whose form accepted the submission, not runs where Companion merely answered. A report is held when lab checks were not all accepted or live delivery tests fell short of 100%, so the client never gets an unexplained "18 of 19". Runner and import: the scheduled run writes one action log entry listing the schedules it skipped for having no care plan (the console line alone is lost for scheduled runs); the advance-on-skip behaviour is unchanged. The recipient importer creates schedules for sites without an active care plan disabled, and says so in the dry run, unless --include-no-care-plan is passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ksums, stabilise performance rotation Standalone (custom-host) enrolment created sites without is_wordpress, so Companion and Renegade sites sat out every WordPress-only job: core checksums, lockout and firewall pulls, plugin detection. - SitesController::store sets is_wordpress: the /health handshake only answers from the WordPress plugin. - A Site saving hook sets is_wordpress whenever the Companion snapshot reports a WordPress core, whichever code path writes the snapshot. It never clears the flag. - A data migration backfills custom-host sites with Companion installed and a WordPress core in their snapshot. clockwork:verify-wp-core-checksums now skips sites that have no way to run wp-cli (no root SSH, not Pressable, no host command runner), using a new WpCoreChecksumVerifier::hasTransport() that mirrors verify()'s dispatch. Before, such a site wrote a failed 'no_command_runner' row every night and turned the whole run red. Skipped sites are counted in the summary and logged by id. This ships with the backfill so the newly flagged sites don't add more failures. The weekly performance rotation bucketed sites by their position in the domain-sorted list, so every enrolment moved every site's weekday. Some sites then went more than a week without a scan, and a month could end with no in-period speed test for a client report. Changes: - Each site's weekday is now crc32(domain) % 7, which doesn't depend on any other site. - Month-end catch-up: in a month's last 7 nights, rotation sites with no OK scan since the 1st join the run, within the daily GTmetrix credit budget (new config clockwork.gtmetrix.daily_credits, default 10). Sites whose own weekday won't come round again that month go first. If credits are short, sites in tonight's bucket that already have a scan this month give up their slot. Anything left waits for the next night instead of falling back to PSI. - Standalone enrolment and switching a care plan on each start one detached baseline scan (--site), on their own background lock. The test base class now binds a no-op DetachedShell, so no Feature test can start a real background artisan process through BackgroundArtisan. Tests that check the launched command still mock it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Brings the branch up to date with main (Companion v1.43.0 standard-plugin install) and merges the fleet-side fixes found while auditing the held October client-report drafts: uptime 429 handling, nightly update retries and ordering, upgrader error classification and redaction, backup relay capability checks and failure state, malware path normalization and new-file alerts, contact-form test cadence, Pressable traffic timing, Bill.com domain matching, and checksum/performance scan coverage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…imestamps They were created in parallel with the same 120000 timestamp. They don't depend on each other, but distinct timestamps make the run order explicit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…iling The backup relay now keeps its failure streak on the site row (backup_relay_failing_since, backup_relay_last_error), but the report only looked at recorded archives, so a relay site with no archive in the period read as "none found" or "could not check" with no hint of why. - compileBackups: a relay (s3_archive) site with no archive in the period whose current failure streak began on or before the period end gets status 'failing'. Like the other unverified statuses it is hidden from the client. The streak start and the last error are kept internally (relay_failing_since, relay_error). The error is trimmed to about 200 characters, secrets are masked and every URL loses its query string and fragment before it is stored. - ReportReviewFlags holds it: "Off-site backups have been failing since <date>: <error>". A relay whose archives stopped part-way through the period keeps the "Backups stopped" reason, now followed by the same sentence. A streak that began after the period is ignored. - The operator preview's hidden-section note shows a red "Failing" badge and the relay's last error. Neither reaches the PDF or the public view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…s it was cut short The report treated every clean SSH-transport malware scan as partial because SSH scans always store scanned_files_count = 0, so SSH-only sites would be held as "Partial scan" every month. Since the scanner now notices an SSH check hitting its result cap (scan_aborted, abort_reason ssh_result_limit, truncated_checks), the file count is no longer the signal. - CompanionMalwareScanner stores details.truncated_checks on every SSH scan, empty when no check hit its cap. Companion-transport scans are unchanged. - compileSecurity: a clean SSH scan is "incomplete" only when it was aborted, or when it has no truncated_checks. Rows without the key were recorded before cut-short SSH scans were detected and always said scan_aborted = false, so a clean one cannot vouch for the whole site; keeping those partial is the safe reading and only affects older rows. The capped check names are kept internally (malware_truncated_checks). - Review reasons now say which applies: an SSH scan that hit its result limit (naming the checks), or one recorded before truncation was tracked. - Security scans docs describe the marker and how reports read it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…s blocked time The uptime monitor now records a rate-limited probe (HTTP 429) as a probe_blocked event and site state instead of a down event with status 429. compileUptime only knew the old shape: a probe_blocked event closed an open outage but opened nothing, so the blocked time was not counted and a block still open at the end of the month no longer produced the "monitor was blocked" review reason. compileUptime now treats a probe_blocked event, and a down event with status 429 from before the change, as the start of a blocked stretch. Blocked time is never downtime and is reported to the operator as probe_blocked_minutes. - down -> probe_blocked -> up: the outage ends at the probe_blocked event and the rest is blocked time. - probe_blocked -> down: the blocked stretch ends and a real outage starts. - An old 429 down followed by a probe_blocked event (deployed mid-block) is one stretch, dated from the old row, so probe_blocked_since and the end-of-month reason name when the block really began. - A probe_blocked stretch carried in from before the period counts as blocked, not down. Tests cover each sequence, including events written by the real UptimeStateUpdater. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…orts that never sends
Held drafts could only be refreshed with the schedule's Send now, which
compiles last month (so from Dec 1 it no longer reaches October), and which
emails a clean report straight away once the schedule has sent before.
- POST /client-reports/{report}/recompile (client-reports.recompile), for
scheduled reports that have not reached anyone: status generated and an
empty sent_to (ClientReport::canRecompile). Sent, partly sent, superseded
and hand-generated reports are refused with a reason.
- ScheduledReportRunner::recompile() compiles the report's own
period_start/period_end from current data, supersedes the old copy, keeps
any custom note, and runs the same review check as a scheduled run
(firstReport when an auto schedule has never sent; a draft schedule gets
no first-report reason, as in run()). It never calls the sender, never
stamps last_sent_at and never moves next_run_at. It re-reads the report
under a lock inside a transaction, so a failed compile leaves the old copy
live and a double click can't recompile a superseded copy.
- "Recompile" buttons on unsent scheduled reports in the history table and
on the operator preview, never on the public view. The preview's review
box points to it, and the reports index and the operator preview now show
the status messages actions return.
Tests assert Mail::fake() saw nothing sent or queued, including for a clean
recompile on a schedule that sends clean reports by itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…sts don't do
Scheduled contact-form tests run in lab mode and suppress the outgoing email,
and a hand-run live test only records whether the site reported the email as
sent; nothing watches the destination inbox. Three places still said
otherwise:
- the Contact Form Testing module description and status note ("delivery
verification", "email round-trip delivery verification");
- the setup page's Maintenance & QA description ("end-to-end SMTP
deliverability verification");
- the client report preview's live-test tile, labelled "Form
Deliverability", now "Form Emails Sent".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…way to refresh drafts The feature page still described the rules from before the October audit fixes. It now covers: the support URL fallback (already present), whole-day periods, the From name, the single show/hide rule, partial-month "Monitoring began" coverage, HTTP 429 blocked time, "Partial scan" (including how SSH scans are read), the core-files row left out on hosts with no checksum path, threats blocked only when measured, slug-joined vulnerable-plugin holds, suspect and failing backups, traffic statuses, contact-form check holds, the care-plan skip log entry and the recipient importer's care-plan gate, schema version 5, and the full list of hold-for-review reasons. A new "Refreshing held drafts (Recompile)" section explains the Recompile action, what it never does, and the steps to refresh drafts after a deploy, and warns against using Send now or --force for that. The tracker's runbook step now points to Recompile instead of Send now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…Test The redaction tests use a JWT-shaped constant that decodes to a fake licence payload and signature. gitleaks' default jwt rule matches it on shape alone, so add its exact fingerprint with an explanation, as the test-fixture convention requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…vered A live contact-form test only records whether the site's mailer reported the notification email as sent; nothing checks the inbox. The preview, HTML and text emails said "to confirm messages reach you" and "N% delivered", and the review reason said the submissions "arrived". Word all of them as what is measured and add a note that the inbox is not checked. Colours and the pass-rate maths are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…nd split site coverage from uptime start Outage times and the "monitoring began" date are shown in the report timezone, but the uptime period and the coverage threshold were UTC days. An outage at 01:00 UTC on the 1st read "Sep 30" under an "Oct 1" header, a late start on the first local day held the report as partial, and one on the last local day read as after the period. - Build the uptime window and the coverage thresholds from the period's calendar days in the report timezone. Sections listing whole days keep their UTC days. - monitoringBeganAfterPeriod and the coverage note compare against the local end of the period; a carried-in outage reads "since before" the local first day (older reports, clamped to midnight UTC, keep their date). - meta.coverage_start is now when the site was added, if that was after the period's first day. The uptime seed only sets uptime.monitored_since, with its own review reason, so monitoring switched on mid-month no longer makes the whole report claim it covers part of the month. - Bump the report schema to 6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ackfill included Upgrader output is redacted when new update results are stored, but rows written before that still hold licence keys and signed download URLs, and the documented recovery after a reinstall (backfill-companion-action-log --reset-since) re-pushed them to the client's wp-admin as stored. Run the summary, details and error through UpgraderOutput on the way out, in ActionLogger's push and in the backfill command. The stored rows are unchanged; the details array keeps its shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…eal file-permission run - A security-fix retry of an auto-paused plugin now follows the same one-attempt-per-version rule as a new release. The same fix version is retried at most once every 7 days, and never when it last failed for a licence or offer reason; a newer fix version is tried at once. - Queueing a retry records the version (and when) on the failure streak, so a retry that times out, is reaped or hits a transport error still counts as that version's attempt instead of being retried every night. - The streak keeps its own count of consecutive file-permission failures and the job that began that run. The site-wide pause needs that run to reach the threshold, checks for a successful update since its start, and states the run's length in its reason. One file-permission failure after failures for other reasons no longer pauses the whole site, and uncounted failures no longer shift the window. Adds a migration for last_retry_queued_at, consecutive_fs_failures and fs_streak_started_job_id on plugin_update_failure_streaks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…real outage open through one A single HTTP 429 moved a site to probe_blocked at once, and from down it closed the outage. A real outage behind an intermittent rate limiter then alternated down and probe_blocked every few probes, re-sending the down chat, SMS and client messages each time and never sending a recovery. One 429 on an up site wrote client-visible failed activity entries and could hold a month's report. - An up or unknown site enters probe_blocked only after as many 429s in a row as it takes failures to go down (new sites.uptime_consecutive_blocked; any other answer resets the run). The event and log are dated at the run's first 429. - A down or maintenance site stays in that state through 429s. A failure after one is the same outage, and a success is its normal recovery with the usual alerts. Only after 429s for the probe-blocked notice hours does it move to probe_blocked, closing the outage at the first 429. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…a scan counts as complete
phpseclib returns the output so far when a command times out, and the
uploads check's xargs stopped silently at the first file name with a
quote, so an SSH check that never finished looked clean. Reports had
started treating a clean SSH scan with an empty truncated_checks as
complete, which turned that into a malware all-clear.
- SshClient::execChecked() throws SshCommandTimedOut on a timeout and
fails when the command couldn't run.
- Each SSH check prints a completion sentinel after its pipeline; a check
that timed out or lost it is listed in incomplete_checks and the scan is
aborted with ssh_check_incomplete. ssh_checks_verified is stored only
when every check finished.
- The uploads check passes paths to sh via find -exec … {} + instead of
xargs, and a pre-check fails the scan when the docroot is missing or
holds no wp-config.php.
- Client reports call a clean SSH scan complete only with
ssh_checks_verified; the review reason names unfinished checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…iod never became bans The blocked-visitors count used any review-queue row created or updated in the period as proof that 0 bans was a measurement. Those rows are the lockouts that weren't banned (waiting for review, or the ban failed), so reports showed "Malicious visitors blocked: 0" for sites that had lockouts on record. - Review-queue rows no longer count as evidence. Lockouts created in the period that are still pending, queued or failed hide the count, even beside real bans, and hold the report with the number of unbanned lockouts (kept internally as unbanned_lockouts). - A server's lockout pull vouches for a 0 only when it ran in the period's last day or later, not merely after the period started. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…udit branch Conflicts resolved: - CHANGELOG.md: main released 1.12.0; this branch's entries move to a new Unreleased section on top of main's file. - SuppressMalwarePath: keep the kind-aware suppression list and main's iterate-the-local-list fix for Larastan. - Client reports list: main's single-column layout and no-wrap status column, plus this branch's flash messages and Outdated badge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The greeting used the site's client name verbatim, which is often an organisation, an initial, a name with an internal note in brackets, or a contact who is not the person the report goes to. Greet by first name only when the report goes to one address that is a known client contact whose stored name is plainly a person's; otherwise say "Hi there,". Same in the HTML and plain-text parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The wpvulnerability.net 'eq' operator was stored as an inclusive upper bound with no lower bound, so an advisory for exactly one release matched every older release and flagged unaffected sites as vulnerable. The operator block is now resolved as a whole: 'eq' gives from = to (both inclusive), the operator (not the min/max slot) decides which side a version bounds, numeric versions are kept instead of opening the range, and no patched_in is claimed for an exact advisory. A data migration repairs existing rows from the advisory title, which carries the feed's own '== X' expression. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
- Date updates by the report timezone's calendar day and measure the updates period over those days, as the uptime section already does, so an update run late in the evening is no longer listed a day late. - Never claim "no updates were needed": with nothing listed, say only that everything was up to date at the end of the month, or that no updates were applied when something was waiting or can't be known. - List version changes made outside Control (plugin_update_observed, theme_update_observed, core_update_observed rows), labelled like any other update, once each: a change Control itself made to the same version is not listed twice. One seen after the period counts as waiting at its end. - Leave the agency's own connector plugins out of the list, the counts and the waiting updates. - Leave out an update the site's later snapshot shows was undone (a lower version now, and a pause, auto-pause or backup restore since), keep it as updates.not_kept and hold the report to say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… month "Malicious visitors blocked" counted every ban in the period, including bans reversed as false positives minutes later, and took any ban in the period as proof the whole month was measured, so a site whose lockouts stopped reaching Control part-way through still showed a monthly total. Leave out bans decided as false positives or lifted within an hour, and show the count only when the site reports its lockouts now and lockouts from the fleet were still arriving within 72 hours of the period end (the pull keeps stamping last_llar_pull_at even when nothing arrives). Otherwise the count is hidden; security.blocks_unmeasured and lockouts_last_ingested_at say why, worded for the operator by ReportPhrases::blockedVisitorsNote(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…id-month A site whose care plan was switched on part-way through the month was reported as a whole month of care. Read the care-plan toggle rows (the site page's toggle and the command-line form) and treat the last switch from off to on inside the period, after its first day in the report timezone, as the coverage start, like a site added mid-month: the later of the two is kept in meta.coverage_start, with meta.coverage_source saying which. The client reads "Your care plan began Oct 28, so this report covers Oct 28 – Oct 31." and the report is held with "The care plan was switched on Oct 28, so this report covers only part of the month." Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…theme WordPress offers a custom theme the releases of any wordpress.org theme that shares its folder name, and the nightly loop applied such an offer, replacing a custom child theme with an unrelated directory theme. Before queueing a theme, the nightly loop now checks that the installed theme is the one on offer: when wordpress.org lists the folder, the names must match; otherwise a child theme (a parent template in the snapshot, or "child" in its name) is refused. A held-back theme gets a skipped job row (reason_code identity_mismatch) for the summary mail, a private identity_mismatch ignore so later nights skip it quietly, a local-only theme_update_refused action log and one chat message. Nothing counts toward failure streaks or auto-pauses, and the streak recorder no longer rewrites such an ignore as an auto-failure. Adds ActionLog::LOCAL_ONLY_TYPES, which ActionLogger and the Companion action-log backfill never mirror to the client's wp-admin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A long outage that ended before the period end went to the client as a bare line with no context, and nothing held the report. Keep every outage of 60 minutes or more (uptime.long_outages, uncapped, so one that fell off the five listed still counts) and hold the report with its date and length so a person adds context first. An outage still open at the end keeps its own "still down" reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…o the HTML - The HTML email showed every availability figure in the "good" green, 97% included. Colour the tile and the row green from 99.9%, amber from 99%, red below (ReportPhrases::uptimeTone). - The plain-text part left out "Data transferred" and what each day's backups held. It now has the same bandwidth line and, per backup day, the kinds and size the HTML shows, from shared ReportPhrases helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
- Traffic: say "one IP address" when every listed request came from one (traffic.top_ip_count), not always "two IP addresses". - Backups: count "N of M days" against the days backups were expected, from when the site's coverage began (backups.expected_days), not the whole month for a site that joined near its end. - Vulnerabilities: an advisory with no patched version but a closed upper bound is fixed in the next version after that bound (vulnerable_plugin_items.fixed_after), not "no fixed version yet"; a waiting update past the bound counts as one that fixes it, and an exclusive bound is itself the fixed version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ends The module now opens on a Clients tab: every client with its sites' care-plan status, report schedule, recipients and a six-month history strip linking to each report, with search and care-plan / waiting / missing-schedule filters. Sites without a client are grouped under "Unassigned". Each client has a reports page with contacts, each site's schedule and full report history (View, Email, Recompile, Send, Send test to me), previous/next through the filtered list and a switcher. "Send test to me" emails the exact client email to the signed-in operator only, with [TEST] in the subject, and changes no report or schedule state. The real Send now confirms who receives it and that a schedule's first send switches it to sending clean reports itself. Care-plan sites without a schedule get a one-click "Create schedule" (default template, monthly, client contacts or drafts). The reports list moves to /client-reports/reports (client-reports.reports). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Control only logged the updates it ran, so anything WordPress auto-updates
or another updater applied first was invisible to the action log and to
everything built on it.
When a new Companion snapshot replaces the previous one (Site model
updated hook, so every snapshot writer is covered), each installed version
that went up and that no recent Control job made is written as one
plugin_update_observed / theme_update_observed / core_update_observed
action log with details {slug, name, from_version, to_version,
observed_at, previous_snapshot_at}. Downgrades, added/removed items and
repeats of the same change are skipped. The rows are local-only: never
mirrored to the client's wp-admin, and they touch no jobs, failure
streaks, auto-pauses or chat alerts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…hedule is With no Control schedule, the recipients sync fell back to the site's care-plan state and pushed "report active" to wp-admin, although nothing sends without a schedule. The fallback payload is now always not active (the client's addresses are still offered as recipients), and a WordPress that an earlier push left showing reports as active while Control has no schedule is told otherwise on its next snapshot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…gs reviewed A held report whose latest malware scan was graded "warning" (review-tier findings only: PHP in uploads, a recently changed wp-config, the dead /e modifier) had no way to clear the client's "Issues flagged" line after a person confirmed the findings were benign. Add "Mark findings reviewed" (POST client-reports.malware-reviewed, one form in the preview's review box) for a held report that hasn't reached anyone. It records who, when and a note in security.malware_reviewed, tied to the scan id and finding kinds the report shows, removes that review reason (the others stay), and both emails then read "Reviewed — no threat found". Suspected malware (issues_found) can never be marked. Recompile, and a scheduled run's fresh compile, carry the mark over only while the scan and its findings are unchanged. Nothing is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The reports list shows one period at a time (the latest by default) and hides replaced copies unless asked. It filters by status (needs review, outdated, ready to send, partly sent, sent, or anything waiting), by hold-reason category derived from the stored review reasons, and by a search over site, client and recipient. A summary strip counts the period's reports per status and doubles as filter links; each row shows short reason chips and labelled View / Email / Recompile / Send / Test actions. Filters live in the query string and survive paging. "Recompile all outdated" for the period and "Recompile selected" run the existing ScheduledReportRunner::recompile() per report, never send, and report how many were recompiled, held, skipped or failed. The Scheduling tab gains filters (no care plan, no recipients, disabled, drafts) with counts, each schedule's latest report state, and a panel of care-plan sites without a schedule with one-click and "create all". Module tabs show how many reports wait and how many sites lack a schedule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
… upstream - Date observed version changes by ran_at (the snapshot that showed the new version) and decode HTML entities in their names; update labels from Control's own runs are decoded too, so "&" never reaches the client. - An advisory for exactly one version (stored from = to) is fixed in the next version after it, never "no fixed version yet". - An update ignore with source identity_mismatch reads "update held back: the offered package appears to be a different theme", not "paused by hand". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The operator preview gets a sticky toolbar (operator mode only, never on the public report page or in print): back to the list it was opened from with its filters and page, previous/next through that filtered list, Email version, Recompile, Print/PDF, Send test to me, and Send, which is disabled with the reason when the report is outdated, replaced or already with everyone. Keys: left/right for previous/next, e for the email version. Review reasons are grouped by category, each with a link to the matching site dashboard tab (or the schedule for a first report). The email version page now uses the app layout: a card with From, To, Sent to and Subject, the grouped review reasons, HTML and plain-text tabs, and the same previous/next and actions as the preview. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…nd toolbar Rewrites the tabs section for the new layout: the Clients landing page and client reports page, the filterable reports list and bulk recompile, the operator preview toolbar and keyboard keys, the app-styled email version, "Test to me", the send confirmation, the Scheduling filters and one-click schedules for care-plan sites without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
A small "Client reports" pill next to the care-plan badge in the site header opens the site's client page (or the Unassigned group) at that site's schedule and report history. Shown only when the Client Reports module's routes are loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Updates, threats blocked, coverage, long outages, traffic, backups and vulnerability items are compiled under new rules, so unsent reports from before must be recompiled before they can be sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…ites With dozens of care-plan sites missing a schedule the panel pushed the schedule table far down; the rest now open with "Show all N". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Greeting, report-timezone update dates, updates applied outside Control, agency plugins and undone updates left out, real-ban counting, mid-month care-plan coverage, long-outage holds, uptime colour, text/HTML parity, clearer review reasons, recipients sync, and an operator 'mark findings reviewed' action. Report schema 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Keeps both sides: the "Mark findings reviewed" form now lives in the grouped review panel (under the malware group) of the operator preview, and its route sits next to the UI's routes with the numeric report constraint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The operator preview, the printout and the public report page now read the same ReportPhrases as both email parts: the malware label (so a reviewed report says "Reviewed — no threat found"), the "no updates" sentence, the uptime figure and colour (green from 99.9%, amber from 99%, red below), backup kinds, dates and sizes, and data transferred, left out like the email when the host reported no byte counts. The operator-only note for a hidden blocked-visitors count gives the shared reason. The care-plan coverage note already came from coverageNote(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
Covers updates dated by the report timezone, updates observed outside Control, the connector plugins and undone updates left out, blocked visitors counted only when measured all month (and the operator's reason when hidden), the care-plan coverage start, long-outage holds, uptime colours, "Mark findings reviewed", the greeting rule and the phrases the report page now shares with both email parts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
The Clients tab now lists care-plan clients (any active site on a care plan) under "Care plan clients — reported monthly" and folds the rest into a collapsed "No care plan" section with a count. A Care plan / No care plan / All switch (default Care plan) and an order toggle (Needs attention, the default, or A–Z) live in the query string with the search and filters, so paging and the client pages keep them; the tiles count the current selection. Sites without a client are split the same way. Previous/next on a client page follows the shown order. The Scheduling tab lists care-plan schedules first and folds the "No care plan — won't send" ones into a collapsed section, opened when filtering for them. The blocked-visitors note on the preview is set in small text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
…d fleet accuracy fixes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7rExNMgxzTNb3pagVUZrg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The first scheduled run of Control's client reports (Nov 1) compiled a month of drafts and held every one for review, which is intended. We audited those drafts against a copy of production data to see whether the reports are good enough to replace ManageWP's. Several reports contained wrong or misleading content, and some of the causes were fleet bugs rather than report bugs. This PR fixes them and adds the UI the team needs to review, navigate and send reports.
How we verified:
Client reports: content
clockwork:recompile-held-client-reports, rebuilds an unsent report from current data and never sends.Client reports: UI
/client-reports, new landing page):/client-reports/reports):Fleet fixes found through the reports
probe_blockedstate instead of down. Real outages stay open through 429s, so down alerts aren't re-sent.reason_code.== X) no longer match every older release. A migration repairs existing rows.backup-create.www.and every fleet TLD.Testing
StorageWritableCheck"real permission error" (it runs as root) and twoInstallerWizardTest"executes full installation" tests, which need a live MySQL server. All three fail the same way onmainthere.main(v1.12.0 and dependency bumps) was tested on its own as well.Upgrade notes
php artisan migrate. New columns onsites,plugin_update_jobsandplugin_update_failure_streaks, a backfill ofsites.is_wordpressfor standalone Companion sites, and a repair of exact-version vulnerability rows.npm run buildand clear the view cache.php artisan queue:restart.clockwork:sync-bill-customers --dry-runandclockwork:sync-bill-care-plans --dry-runand review them before the next real sync. Normalized matching can linkwww.sites and change care-plan flags.php artisan clockwork:recompile-held-client-reports --outdated --dry-run, then again without--dry-run. It never sends.CLOCKWORK_GTMETRIX_DAILY_CREDITS, and optionallyCLOCKWORK_REPORTS_TIMEZONE. The app host needs HTTPS access toapi.wordpress.org./client-reportsnow opens the Clients tab. The reports list moved to/client-reports/reports.plugin_update_jobs.messagesandaction_logs.detailsstill contain unredacted upgrader output. Scrub them separately.Not in this PR
Generated by Claude Code