| 1 |
<?php |
| 2 |
/** |
| 3 |
* Code Blue — the log model. |
| 4 |
* |
| 5 |
* Discovery, tailing, parsing and clearing of the logs an install can |
| 6 |
* produce — the server half. Grouping, filtering and time buckets |
| 7 |
* run in the browser (`code-blue.os.ts`) so every filter is instant. |
| 8 |
* Everything here is a plain namespaced function that talks to the |
| 9 |
* host only through `$os`, so the same code runs on WordPress and on |
| 10 |
* a bare PHP host. |
| 11 |
* |
| 12 |
* @package OpenStation |
| 13 |
*/ |
| 14 |
|
| 15 |
namespace OpenStation\Apps\CodeBlue; |
| 16 |
|
| 17 |
use OpenStation\App\Os; |
| 18 |
|
| 19 |
// Direct access, unless a standalone host is booting on bare PHP. |
| 20 |
if ( ! defined( 'ABSPATH' ) ) { |
| 21 |
defined( 'OPENSTATION_STANDALONE' ) || exit; |
| 22 |
} |
| 23 |
|
| 24 |
const MAX_BYTES = 1048576; |
| 25 |
const MAX_ENTRIES = 3000; |
| 26 |
|
| 27 |
/** |
| 28 |
* Whether the acting user may use Code Blue: site management (network |
| 29 |
* management on a network — the logs are shared files) AND Developer |
| 30 |
* mode in OpenStation Preferences. |
| 31 |
* |
| 32 |
* @param Os $os Host handle. |
| 33 |
* @return bool |
| 34 |
*/ |
| 35 |
function can_use( Os $os ) { |
| 36 |
$can = $os->can( $os->env->is_network() ? 'manage_network_options' : 'manage_options' ) |
| 37 |
&& ! empty( $os->preference( 'developerModeEnabled' ) ); |
| 38 |
|
| 39 |
/** |
| 40 |
* Filter whether the current user can see the Code Blue window. |
| 41 |
* |
| 42 |
* @param bool $can Default: Developer mode on AND `manage_options` |
| 43 |
* (`manage_network_options` on a network). |
| 44 |
*/ |
| 45 |
return (bool) $os->filter( 'openstation_code_blue_user_can_use', $can ); |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* The PHP error-label → severity map; the single source of truth for |
| 50 |
* both the parse regex and the label lookup. |
| 51 |
* |
| 52 |
* @return array<string,string> |
| 53 |
*/ |
| 54 |
function level_map() { |
| 55 |
return array( |
| 56 |
'fatal error' => 'fatal', |
| 57 |
'parse error' => 'fatal', |
| 58 |
'core error' => 'fatal', |
| 59 |
'compile error' => 'fatal', |
| 60 |
'recoverable fatal error' => 'fatal', |
| 61 |
'user error' => 'error', |
| 62 |
'warning' => 'warning', |
| 63 |
'core warning' => 'warning', |
| 64 |
'compile warning' => 'warning', |
| 65 |
'user warning' => 'warning', |
| 66 |
'deprecated' => 'deprecated', |
| 67 |
'user deprecated' => 'deprecated', |
| 68 |
'notice' => 'notice', |
| 69 |
'user notice' => 'notice', |
| 70 |
); |
| 71 |
} |
| 72 |
|
| 73 |
/** |
| 74 |
* Grouping key: numbers and hex addresses collapse so two occurrences |
| 75 |
* of the same problem land together. |
| 76 |
* |
| 77 |
* @param string $level Severity slug. |
| 78 |
* @param string $message Message without the location suffix. |
| 79 |
* @param string $file File path, may be ''. |
| 80 |
* @return string |
| 81 |
*/ |
| 82 |
function signature( $level, $message, $file = '' ) { |
| 83 |
$norm = preg_replace( '/0x[0-9a-f]+/i', 'N', (string) $message ); |
| 84 |
$norm = preg_replace( '/\d+/', 'N', (string) $norm ); |
| 85 |
$norm = preg_replace( '/\s+/', ' ', trim( (string) $norm ) ); |
| 86 |
return $level . '|' . substr( (string) $norm, 0, 240 ) . '|' . $file; |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Build one entry: strip well-formed HTML (`_doing_it_wrong()` logs |
| 91 |
* markup; a bare `<` from a parse error must survive), pull the |
| 92 |
* `in /file on line N` suffix out, derive the signature. |
| 93 |
* |
| 94 |
* @param int|null $timestamp Unix seconds or null. |
| 95 |
* @param string $level Severity slug. |
| 96 |
* @param string $label Human label, e.g. `PHP Fatal error`. |
| 97 |
* @param string $message Message text. |
| 98 |
* @return array<string,mixed> |
| 99 |
*/ |
| 100 |
function make_entry( $timestamp, $level, $label, $message ) { |
| 101 |
$message = trim( (string) preg_replace( '/<\/?[a-zA-Z][^<>]*>/', '', (string) $message ) ); |
| 102 |
$file = ''; |
| 103 |
$line = 0; |
| 104 |
if ( preg_match( '/^(.*?)\s+in\s+(\S+?)(?::(\d+)|\s+on\s+line\s+(\d+))$/s', $message, $m ) ) { |
| 105 |
$message = trim( $m[1] ); |
| 106 |
$file = $m[2]; |
| 107 |
$line = (int) ( '' !== $m[3] ? $m[3] : $m[4] ); |
| 108 |
} |
| 109 |
return array( |
| 110 |
'timestamp' => $timestamp, |
| 111 |
'level' => $level, |
| 112 |
'label' => $label, |
| 113 |
'message' => $message, |
| 114 |
'file' => $file, |
| 115 |
'line' => $line, |
| 116 |
'trace' => '', |
| 117 |
'signature' => signature( $level, $message, $file ), |
| 118 |
); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Whose code is this? The first question anyone asks of a log line, |
| 123 |
* and the last one the log itself answers — the path is right there in |
| 124 |
* every entry, and reading `wp-content/plugins/<slug>/` off it turns |
| 125 |
* "some fatal error" into "WooCommerce's fatal error", which is the |
| 126 |
* difference between triage and archaeology. |
| 127 |
* |
| 128 |
* Deliberately conservative: a path that is not clearly under the |
| 129 |
* content directory or clearly inside core answers `unknown` rather |
| 130 |
* than guessing, because a wrong attribution sends someone into the |
| 131 |
* wrong codebase. Single-file plugins and mu-plugins keep their |
| 132 |
* basename as the slug; there is no directory to name them by. |
| 133 |
* |
| 134 |
* This is the ONLY implementation. The window renders what it returns |
| 135 |
* and the debugging abilities report it verbatim — a second copy of |
| 136 |
* this classification would let the two disagree about whose bug it is. |
| 137 |
* |
| 138 |
* @param string $file Absolute path from a log entry, may be ''. |
| 139 |
* @param string $content_dir The install's `wp-content` equivalent. |
| 140 |
* @return array{kind:string,slug:string} `kind` is one of `plugin`, |
| 141 |
* `mu-plugin`, `theme`, `core`, `unknown`. |
| 142 |
*/ |
| 143 |
function origin( $file, $content_dir ) { |
| 144 |
$path = str_replace( '\\', '/', (string) $file ); |
| 145 |
$content = rtrim( str_replace( '\\', '/', (string) $content_dir ), '/' ); |
| 146 |
if ( '' !== $content && 0 === strpos( $path, $content . '/' ) ) { |
| 147 |
$rest = substr( $path, strlen( $content ) + 1 ); |
| 148 |
if ( preg_match( '#^(plugins|mu-plugins|themes)/([^/]+)#', $rest, $m ) ) { |
| 149 |
$kinds = array( |
| 150 |
'plugins' => 'plugin', |
| 151 |
'mu-plugins' => 'mu-plugin', |
| 152 |
'themes' => 'theme', |
| 153 |
); |
| 154 |
return array( |
| 155 |
'kind' => $kinds[ $m[1] ], |
| 156 |
'slug' => preg_replace( '/\.php$/', '', $m[2] ), |
| 157 |
); |
| 158 |
} |
| 159 |
} |
| 160 |
if ( preg_match( '#/wp-(admin|includes)/#', $path ) ) { |
| 161 |
return array( |
| 162 |
'kind' => 'core', |
| 163 |
'slug' => '', |
| 164 |
); |
| 165 |
} |
| 166 |
return array( |
| 167 |
'kind' => 'unknown', |
| 168 |
'slug' => '', |
| 169 |
); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Parse `22-Aug-2026 09:14:02 UTC` (or the same without a zone, |
| 174 |
* read as UTC). |
| 175 |
* |
| 176 |
* @param string $raw Text between the brackets. |
| 177 |
* @return int|null |
| 178 |
*/ |
| 179 |
function parse_timestamp( $raw ) { |
| 180 |
$raw = trim( (string) $raw ); |
| 181 |
$date = \DateTime::createFromFormat( 'd-M-Y H:i:s T', $raw ); |
| 182 |
if ( false === $date ) { |
| 183 |
$date = \DateTime::createFromFormat( 'd-M-Y H:i:s', $raw, new \DateTimeZone( 'UTC' ) ); |
| 184 |
} |
| 185 |
if ( false === $date ) { |
| 186 |
$fallback = strtotime( $raw ); |
| 187 |
return false === $fallback ? null : $fallback; |
| 188 |
} |
| 189 |
return $date->getTimestamp(); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Parse raw log text into entries, oldest first. |
| 194 |
* |
| 195 |
* Understands `[stamp] PHP <label>: message in /file on line N` (and |
| 196 |
* the `:N` form), `[stamp] WordPress database error … for query …`, |
| 197 |
* Xdebug frames, untimestamped trace lines (attached to the previous |
| 198 |
* entry), and treats anything else as an `info` entry of its own. |
| 199 |
* |
| 200 |
* @param string $raw Raw log text. |
| 201 |
* @return array[] |
| 202 |
*/ |
| 203 |
function parse( $raw ) { |
| 204 |
$entries = array(); |
| 205 |
$current = null; |
| 206 |
$labels_re = implode( '|', array_map( 'preg_quote', array_keys( level_map() ) ) ); |
| 207 |
$log_label = __( 'Log', 'desktop-mode' ); |
| 208 |
|
| 209 |
foreach ( preg_split( '/\r\n|\n|\r/', (string) $raw ) as $line ) { |
| 210 |
if ( '' === trim( $line ) ) { |
| 211 |
continue; |
| 212 |
} |
| 213 |
if ( ! preg_match( '/^\[(\d{1,2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}(?:\s+[A-Za-z0-9_\/+:\-]+)?)\]\s?(.*)$/', $line, $m ) ) { |
| 214 |
if ( null !== $current && preg_match( '/^(Stack trace:|#\d+|thrown in\b|\s)/', $line ) ) { |
| 215 |
$current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $line ); |
| 216 |
continue; |
| 217 |
} |
| 218 |
if ( null !== $current ) { |
| 219 |
$entries[] = $current; |
| 220 |
} |
| 221 |
$current = make_entry( null, 'info', $log_label, trim( $line ) ); |
| 222 |
continue; |
| 223 |
} |
| 224 |
|
| 225 |
$timestamp = parse_timestamp( $m[1] ); |
| 226 |
$rest = $m[2]; |
| 227 |
if ( null !== $current && preg_match( '/^PHP (Stack trace:|\s*\d+\.\s)/', $rest ) ) { |
| 228 |
$current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $rest ); |
| 229 |
continue; |
| 230 |
} |
| 231 |
if ( null !== $current ) { |
| 232 |
$entries[] = $current; |
| 233 |
} |
| 234 |
|
| 235 |
if ( preg_match( '/^PHP (' . $labels_re . ')\s*:\s*(.*)$/i', $rest, $em ) ) { |
| 236 |
$map = level_map(); |
| 237 |
$current = make_entry( $timestamp, $map[ strtolower( trim( $em[1] ) ) ], 'PHP ' . $em[1], $em[2] ); |
| 238 |
continue; |
| 239 |
} |
| 240 |
if ( preg_match( '/^WordPress database error\s+(.*)$/', $rest, $dm ) ) { |
| 241 |
$message = $dm[1]; |
| 242 |
$trace = ''; |
| 243 |
$split = strpos( $message, ' for query ' ); |
| 244 |
if ( false !== $split ) { |
| 245 |
$trace = 'Query: ' . substr( $message, $split + 11 ); |
| 246 |
$message = substr( $message, 0, $split ); |
| 247 |
$made_by = strpos( $trace, ' made by ' ); |
| 248 |
if ( false !== $made_by ) { |
| 249 |
$trace = substr( $trace, 0, $made_by ) . "\nMade by: " . substr( $trace, $made_by + 9 ); |
| 250 |
} |
| 251 |
} |
| 252 |
$current = make_entry( $timestamp, 'error', __( 'Database error', 'desktop-mode' ), $message ); |
| 253 |
$current['trace'] = $trace; |
| 254 |
continue; |
| 255 |
} |
| 256 |
$current = make_entry( $timestamp, 'info', $log_label, $rest ); |
| 257 |
} |
| 258 |
if ( null !== $current ) { |
| 259 |
$entries[] = $current; |
| 260 |
} |
| 261 |
return $entries; |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* Read the trailing window of a file, dropping the first (partial) |
| 266 |
* line when the file was longer than the window. |
| 267 |
* |
| 268 |
* @param string $path Absolute path. |
| 269 |
* @param int $max_bytes Window size. |
| 270 |
* @return array{raw:string,truncated:bool,scanned_bytes:int} |
| 271 |
*/ |
| 272 |
function tail( $path, $max_bytes ) { |
| 273 |
$result = array( |
| 274 |
'raw' => '', |
| 275 |
'truncated' => false, |
| 276 |
'scanned_bytes' => 0, |
| 277 |
); |
| 278 |
if ( ! is_file( $path ) || ! is_readable( $path ) ) { |
| 279 |
return $result; |
| 280 |
} |
| 281 |
$size = (int) filesize( $path ); |
| 282 |
if ( 0 === $size ) { |
| 283 |
return $result; |
| 284 |
} |
| 285 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming the tail of a multi-megabyte log; WP_Filesystem has no seek-and-read. |
| 286 |
$handle = fopen( $path, 'rb' ); |
| 287 |
if ( ! $handle ) { |
| 288 |
return $result; |
| 289 |
} |
| 290 |
$offset = max( 0, $size - $max_bytes ); |
| 291 |
if ( $offset > 0 ) { |
| 292 |
fseek( $handle, $offset ); |
| 293 |
$result['truncated'] = true; |
| 294 |
} |
| 295 |
$raw = stream_get_contents( $handle ); |
| 296 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen above. |
| 297 |
fclose( $handle ); |
| 298 |
if ( false === $raw ) { |
| 299 |
return $result; |
| 300 |
} |
| 301 |
if ( $offset > 0 ) { |
| 302 |
$newline = strpos( $raw, "\n" ); |
| 303 |
$raw = false === $newline ? '' : substr( $raw, $newline + 1 ); |
| 304 |
} |
| 305 |
$result['raw'] = $raw; |
| 306 |
$result['scanned_bytes'] = strlen( $raw ); |
| 307 |
return $result; |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* The log files this install offers, normalised with file metadata. |
| 312 |
* |
| 313 |
* @param Os $os Host handle. |
| 314 |
* @return array[] Each: `id`, `label`, `path`, `exists`, `readable`, `writable`, `size`, `mtime`. |
| 315 |
*/ |
| 316 |
function sources( Os $os ) { |
| 317 |
$sources = array(); |
| 318 |
$debug_log = $os->env->constant( 'WP_DEBUG_LOG', false ); |
| 319 |
$debug_path = ''; |
| 320 |
if ( is_string( $debug_log ) && '' !== $debug_log ) { |
| 321 |
$debug_path = $debug_log; |
| 322 |
} elseif ( $debug_log || file_exists( $os->env->content_dir() . '/debug.log' ) ) { |
| 323 |
$debug_path = $os->env->content_dir() . '/debug.log'; |
| 324 |
} |
| 325 |
if ( '' !== $debug_path ) { |
| 326 |
$sources[] = array( |
| 327 |
'id' => 'debug-log', |
| 328 |
'label' => __( 'WordPress debug log', 'desktop-mode' ), |
| 329 |
'path' => $debug_path, |
| 330 |
); |
| 331 |
} |
| 332 |
|
| 333 |
$ini_log = (string) ini_get( 'error_log' ); |
| 334 |
if ( '' !== $ini_log && ! in_array( $ini_log, array( 'syslog', '/dev/stderr', '/dev/stdout' ), true ) ) { |
| 335 |
$same = '' !== $debug_path && ( $ini_log === $debug_path |
| 336 |
|| ( file_exists( $ini_log ) && file_exists( $debug_path ) && realpath( $ini_log ) === realpath( $debug_path ) ) ); |
| 337 |
if ( ! $same ) { |
| 338 |
$sources[] = array( |
| 339 |
'id' => 'php-error-log', |
| 340 |
'label' => __( 'PHP error log', 'desktop-mode' ), |
| 341 |
'path' => $ini_log, |
| 342 |
); |
| 343 |
} |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* Filter the log sources Code Blue offers. Each: `id`, `label`, |
| 348 |
* `path` — metadata is derived after filtering. |
| 349 |
* |
| 350 |
* @param array[] $sources Default: WP debug log + PHP error log. |
| 351 |
*/ |
| 352 |
$sources = $os->filter( 'openstation_code_blue_log_sources', $sources ); |
| 353 |
|
| 354 |
$out = array(); |
| 355 |
$seen = array(); |
| 356 |
foreach ( (array) $sources as $source ) { |
| 357 |
$id = isset( $source['id'] ) ? strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $source['id'] ) ) : ''; |
| 358 |
$path = isset( $source['path'] ) ? (string) $source['path'] : ''; |
| 359 |
if ( '' === $id || '' === $path || isset( $seen[ $id ] ) ) { |
| 360 |
continue; |
| 361 |
} |
| 362 |
$seen[ $id ] = true; |
| 363 |
$exists = is_file( $path ); |
| 364 |
$out[] = array( |
| 365 |
'id' => $id, |
| 366 |
'label' => isset( $source['label'] ) ? (string) $source['label'] : $id, |
| 367 |
'path' => $path, |
| 368 |
'exists' => $exists, |
| 369 |
'readable' => $exists && is_readable( $path ), |
| 370 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Probing a server-side log path from the host-agnostic model; `wp_is_writable()` does not exist on a standalone host. |
| 371 |
'writable' => $exists && is_writable( $path ), |
| 372 |
'size' => $exists ? (int) filesize( $path ) : 0, |
| 373 |
'mtime' => $exists ? (int) filemtime( $path ) : 0, |
| 374 |
); |
| 375 |
} |
| 376 |
return $out; |
| 377 |
} |
| 378 |
|
| 379 |
/** |
| 380 |
* Whether a source can be read as a log: a missing file is an EMPTY |
| 381 |
* log; only exists-but-unreadable is dead. |
| 382 |
* |
| 383 |
* @param array<string,mixed> $source Normalised source. |
| 384 |
* @return bool |
| 385 |
*/ |
| 386 |
function usable( array $source ) { |
| 387 |
return $source['readable'] || ! $source['exists']; |
| 388 |
} |
| 389 |
|
| 390 |
/** |
| 391 |
* Read + parse one source. |
| 392 |
* |
| 393 |
* Deliberately uncached. A log reader's product is freshness, and an |
| 394 |
* object cache would have to be keyed on more than the file: the |
| 395 |
* `entries` / `max_bytes` / `max_entries` filters all shape the result, |
| 396 |
* and `parse()` bakes localized level labels into it — on a Redis or |
| 397 |
* Memcached install a filter change would lag and two admins in |
| 398 |
* different locales would read each other's labels. A bounded tail |
| 399 |
* (1 MB by default) parsed on an explicit Refresh is cheap enough that |
| 400 |
* none of that is worth buying. |
| 401 |
* |
| 402 |
* @param Os $os Host handle. |
| 403 |
* @param array<string,mixed> $source Normalised source. |
| 404 |
* @return array{entries:array[],truncated:bool,scanned_bytes:int,dropped:int,error:string} |
| 405 |
*/ |
| 406 |
function read( Os $os, array $source ) { |
| 407 |
$empty = array( |
| 408 |
'entries' => array(), |
| 409 |
'truncated' => false, |
| 410 |
'scanned_bytes' => 0, |
| 411 |
'dropped' => 0, |
| 412 |
'error' => '', |
| 413 |
); |
| 414 |
if ( ! $source['exists'] ) { |
| 415 |
return $empty; |
| 416 |
} |
| 417 |
if ( ! $source['readable'] ) { |
| 418 |
$empty['error'] = __( 'The log file exists but PHP cannot read it.', 'desktop-mode' ); |
| 419 |
return $empty; |
| 420 |
} |
| 421 |
|
| 422 |
$max_bytes = max( 4096, (int) $os->filter( 'openstation_code_blue_max_bytes', MAX_BYTES ) ); |
| 423 |
$max_entries = max( 100, (int) $os->filter( 'openstation_code_blue_max_entries', MAX_ENTRIES ) ); |
| 424 |
$tail = tail( $source['path'], $max_bytes ); |
| 425 |
/** |
| 426 |
* Filter the parsed entries for one source — re-parse `$raw` |
| 427 |
* yourself for a format the built-in parser doesn't know. |
| 428 |
* |
| 429 |
* @param array[] $entries Parsed entries, oldest first. |
| 430 |
* @param array $source Normalised source. |
| 431 |
* @param string $raw The scanned tail. |
| 432 |
*/ |
| 433 |
$entries = (array) $os->filter( 'openstation_code_blue_entries', parse( $tail['raw'] ), $source, $tail['raw'] ); |
| 434 |
|
| 435 |
// Attribution runs AFTER the filter so entries a plugin's own parser |
| 436 |
// contributed are attributed too — `parse()` never sees them. |
| 437 |
$content_dir = $os->env->content_dir(); |
| 438 |
foreach ( $entries as $index => $entry ) { |
| 439 |
$entries[ $index ]['origin'] = origin( isset( $entry['file'] ) ? $entry['file'] : '', $content_dir ); |
| 440 |
} |
| 441 |
|
| 442 |
$dropped = max( 0, count( $entries ) - $max_entries ); |
| 443 |
if ( $dropped > 0 ) { |
| 444 |
$entries = array_slice( $entries, -$max_entries ); |
| 445 |
} |
| 446 |
return array_merge( |
| 447 |
$empty, |
| 448 |
array( |
| 449 |
'entries' => $entries, |
| 450 |
'truncated' => $tail['truncated'] || $dropped > 0, |
| 451 |
'scanned_bytes' => $tail['scanned_bytes'], |
| 452 |
'dropped' => $dropped, |
| 453 |
) |
| 454 |
); |
| 455 |
} |
| 456 |
|
| 457 |
/** |
| 458 |
* Truncate a source's file. |
| 459 |
* |
| 460 |
* @param Os $os Host handle. |
| 461 |
* @param array<string,mixed> $source Normalised source. |
| 462 |
* @return true|string `true`, or an error message. |
| 463 |
*/ |
| 464 |
function clear( Os $os, array $source ) { |
| 465 |
if ( ! $source['exists'] ) { |
| 466 |
return true; |
| 467 |
} |
| 468 |
if ( ! $source['writable'] ) { |
| 469 |
return __( 'The log file is not writable, so it cannot be cleared.', 'desktop-mode' ); |
| 470 |
} |
| 471 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Truncating a server-side log the descriptor already validated. |
| 472 |
if ( false === file_put_contents( $source['path'], '' ) ) { |
| 473 |
return __( 'Clearing the log file failed.', 'desktop-mode' ); |
| 474 |
} |
| 475 |
/** |
| 476 |
* Fires after Code Blue truncates a log file. |
| 477 |
* |
| 478 |
* @param string $id Source id. |
| 479 |
* @param string $path Absolute path. |
| 480 |
*/ |
| 481 |
$os->action( 'openstation_code_blue_log_cleared', $source['id'], $source['path'] ); |
| 482 |
return true; |
| 483 |
} |
| 484 |
|
| 485 |
/** |
| 486 |
* The URL template the "Search the web" link on an issue resolves |
| 487 |
* against — `%s` is the URL-encoded message. Looking an unfamiliar |
| 488 |
* error up is the next thing anyone does after reading it, and where |
| 489 |
* they look is a house style: an agency may want its own wiki, a |
| 490 |
* hosting platform its own knowledge base. |
| 491 |
* |
| 492 |
* Return an empty string to drop the link entirely. |
| 493 |
* |
| 494 |
* @param Os $os Host handle. |
| 495 |
* @return string URL template containing `%s`, or '' for no link. |
| 496 |
*/ |
| 497 |
function search_url( Os $os ) { |
| 498 |
/** |
| 499 |
* Filter the search URL template for a log message. |
| 500 |
* |
| 501 |
* @param string $template `%s` is replaced with the URL-encoded |
| 502 |
* message. Empty string hides the link. |
| 503 |
*/ |
| 504 |
return (string) $os->filter( 'openstation_code_blue_search_url', 'https://duckduckgo.com/?q=%s' ); |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* The environment card: debug switches and versions. |
| 509 |
* |
| 510 |
* @param Os $os Host handle. |
| 511 |
* @return array[] Each: `label`, `value`, `on` (bool|null). |
| 512 |
*/ |
| 513 |
function environment( Os $os ) { |
| 514 |
$rows = array(); |
| 515 |
foreach ( array( 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES' ) as $constant ) { |
| 516 |
$on = (bool) $os->env->constant( $constant, false ); |
| 517 |
$rows[] = array( |
| 518 |
'label' => $constant, |
| 519 |
'value' => $on ? 'on' : 'off', |
| 520 |
'on' => $on, |
| 521 |
); |
| 522 |
} |
| 523 |
$platform = $os->env->platform(); |
| 524 |
$rows[] = array( |
| 525 |
'label' => __( 'Environment', 'desktop-mode' ), |
| 526 |
'value' => $os->env->environment_type(), |
| 527 |
'on' => null, |
| 528 |
); |
| 529 |
$rows[] = array( |
| 530 |
'label' => 'PHP', |
| 531 |
'value' => PHP_VERSION, |
| 532 |
'on' => null, |
| 533 |
); |
| 534 |
$rows[] = array( |
| 535 |
'label' => $platform['name'], |
| 536 |
'value' => $platform['version'], |
| 537 |
'on' => null, |
| 538 |
); |
| 539 |
|
| 540 |
/** |
| 541 |
* Filter the environment rows shown in the Code Blue window. |
| 542 |
* |
| 543 |
* @param array[] $rows Each: `label`, `value`, `on` (bool|null). |
| 544 |
*/ |
| 545 |
return (array) $os->filter( 'openstation_code_blue_environment', $rows ); |
| 546 |
} |
| 547 |
|