diff --git a/assets/css/desktop-files.css b/assets/css/desktop-files.css index be0315a0..b3f7a25d 100644 --- a/assets/css/desktop-files.css +++ b/assets/css/desktop-files.css @@ -58,6 +58,19 @@ pointer-events: none; } +/* + * The wallpaper's layer starts where the work area starts. The grid + * canvas is the work area's SIZE (`gridCanvasFor()`), and tiles are + * placed from the layer's top-left, so a band claimed at the top or + * left — a reserved band (`wp.os.workArea.reserve()`), a dock there — + * must move the layer's origin too, or the first row/column of icons + * lands under it. Folder windows' layers fill their own canvas. + */ +#os-area > .os-files-layer { + top: var( --os-work-area-inset-top, 0px ); + left: var( --os-work-area-inset-left, 0px ); +} + /* * Live drop-cell preview. A soft outline that hovers at the cell * the dropped tile will land in, so the user can predict where the diff --git a/desktop-mode.php b/desktop-mode.php index 90bc3060..6ab2dcdc 100644 --- a/desktop-mode.php +++ b/desktop-mode.php @@ -171,6 +171,7 @@ function openstation_request_needs_admin_modules() { // Content-changes must load before the recycle bin — the bin's // changelog delegates into the generic recorder. require_once OPENSTATION_DIR . 'includes/content-changes.php'; +require_once OPENSTATION_DIR . 'includes/pulse-monitor.php'; require_once OPENSTATION_DIR . 'includes/recycle-bin/bootstrap.php'; require_once OPENSTATION_DIR . 'includes/desktop-files/bootstrap.php'; require_once OPENSTATION_DIR . 'includes/desktop-themes/bootstrap.php'; diff --git a/docs/README.md b/docs/README.md index 487910f0..2d0d7b01 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ If you are **building a plugin** that interacts with the desktop shell — opens 15. **[Files on the Desktop](./files-on-desktop.md)** — *Experimental.* `OpenStation_File` base class, `openstation_register_file_type()`, and `wp.os.files.*`. The registry, folders, opener associations with per-user overrides, folder sharing, real file storage, and the Trash pin-to-desktop flow are all live. 16. **[Desktop Themes](./desktop-themes.md)** — *Experimental.* Whole-OS reskins uploaded as a ZIP of `theme.json` plus images and fonts: every design token, the typeface, a texture on any of 24 surfaces (chrome, dock, desk, menus, dialogs, tables, buttons) plus a documented way to add your own, and a complete iconset down to the window control glyphs. No author CSS or JS ever executes — PHP validates the manifest and compiles the stylesheet, `@font-face` rules included. Read before authoring a theme, or before touching the texture and typography tokens in `variables.css`. See also [examples/register-desktop-theme.md](./examples/register-desktop-theme.md). 17. **[Folder Sharing](./folder-sharing.md)** — *Experimental.* Per-principal read / write grants on desktop folders with first-sight opt-in, polymorphic `target_type` schema, If-Match conflict detection, and a ``-based Share Settings UI. +17a. **[Pulse Monitor](./pulse-monitor.md)** — *Experimental.* An ECG wired to the site, as a wallpaper and a widget that share one engine, one signal hub and one settings bag: every request, page load, Heartbeat tick, click, visitor, comment and login is a labelled beat; failed (amber) beats say why; and the rhythm is a health diagnosis (sinus, PVCs, atrial fibrillation, ventricular tachycardia, flatline). Covers the signal sources, the Heartbeat server feed and its cost model, the `openstation_pulse_*` filters, the `pulse/beat` channel and the `os.pulse.beat` action. 18. **[Mio](./mio.md)** — *Experimental.* The desk companion: a PixiJS soft-body blob with a chroma neon outline that floats over the wallpaper, feels the gravity of nearby windows and settles onto them, watches your cursor (including across window iframes), and can be dragged anywhere. Covers the simulation, the four soft-body failure modes worth knowing before touching it, the `openstation_mio_config` filter, and `wp.os.mio`. 19. **[Icons](./icons.md)**: *Stable.* The thirty-icon set and the one rule behind it: Core owns the verbs, OpenStation owns the nouns. Covers `osIcon()` / `osIconSvg()` / `osIconDataUri()`, the sizing floor below which a Core glyph goes faint, the three groups that stay hand-drawn and why, and how the same eleven reach WordPress's own icon registry as outlines. Read before drawing any glyph. 20. **[Native Desktop Host](./desktop-host.md)** — *Experimental.* The optional Electron layer, shipped as an **extension** so core never mentions Electron: any window can be **set free** into a real OS window ("Send to your Mac"). Covers the two generic core capabilities it stands on (`wp.os.registerWindowAction()` and `?openstation_solo=`), the capability-probe detection model, and the deliberately cheap liveness pulse. Read before touching the ⋯ menu or solo mode. diff --git a/docs/data-model.md b/docs/data-model.md index 6cd4e8c8..6bc62f41 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -208,6 +208,7 @@ The sections below name the exact tables and keys. | OAuth relay | | | | | | ● | | | PWA | | ● | | | | ● | | | Content changes feed | | | | | ● | | | +| Pulse Monitor | | | | | ● | | | | Caches: content graph, Woo, feeds, stats | | | | | ● | ● | | | Migrations | | | | | ● | | | @@ -296,6 +297,8 @@ profile screen). AI agents are ordinary `wp_users` rows flagged with | `desktop_mode_terms_cache_version` | Content graph | Cache version for terms. | | `_desktop_mode_presence` | Presence | Snapshot of every user's presence (`autoload = no`). Heartbeat updates it with a write throttle; a daily cron prunes it. | | `_desktop_mode_content_changes_log` | Content changes | Recent content changes for the feed, capped at 100 entries (`autoload = no`). | +| `openstation_pulse_log` | Pulse Monitor | `{ seq, entries }`: the rolling log of site-wide requests relayed to listening monitors over Heartbeat, at most 200 rows and two minutes old (`autoload = no`). A row is `{ seq, ts, kind, status, error, user }`; no URL, IP or user agent. Written on `shutdown` only while someone listens. See [Pulse Monitor](pulse-monitor.md). | +| `openstation_pulse_listeners` | Pulse Monitor | Listener leases, user id → unix expiry (`autoload = yes`, read on every request's `shutdown`). A Heartbeat tick from a mounted monitor extends its user's lease for three minutes, rewriting it past half-life only. | | `_desktop_mode_recycle_bin_change_ts` | Recycle Bin | Timestamp of the last bin change, for the badge (`autoload = no`). | | `openstation_app_store` | App Framework | The `Store` contract with `site` scope. | | `openstation_installed_at` | First run | `{ at, via }`: when the plugin was activated (`autoload = no`). `via` is `activation` from the activation hook, `backfill` when reconstructed on `admin_init` for an install that predates the stamp; a backfilled age reads as unknown everywhere. | diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index 36558166..b177e51e 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -1854,6 +1854,32 @@ apply_filters( 'openstation_wallpapers', array $registry ); --- +### Pulse Monitor filters — Experimental + +The server feed behind the built-in Pulse Monitor wallpaper and widget (`includes/pulse-monitor.php`). Full design in [Pulse Monitor](./pulse-monitor.md). + +```php +// Capability required to receive the site-wide feed. Default 'manage_options'. +apply_filters( 'openstation_pulse_capability', string $capability ); + +// Kind a request is classified as ('visitors', 'bots', 'api', 'team', 'cron'). +// Return '' to keep the request off the monitor. +apply_filters( 'openstation_pulse_request_kind', string $kind ); + +// Whether a user agent counts as a bot (bot beats draw smaller). +apply_filters( 'openstation_pulse_is_bot', bool $is_bot, string $user_agent ); + +// One event before it is logged: { kind, status, error, user }. Return false to drop it. +apply_filters( 'openstation_pulse_event', array|false $row ); + +// Health snapshot sent with every Heartbeat: { siteHealth, requests, failures, cronLate }. +apply_filters( 'openstation_pulse_health', array $snapshot ); +``` + +`openstation_pulse_record( string $kind, array $args = array() )` — *Experimental (PHP function).* Queue a beat for the current request (`$args['error']` draws it as a failed beat). Explicit events replace the request's own classification, so a comment POST beats once, as a comment. + +--- + ### `openstation_network_enabled` — Experimental Whether the OpenStation Network is enabled site-wide. The default comes from the `network` extended option (OpenStation Preferences → Features → Extended options, admins only) — **off by default**: the network is opt-in. When the resolved value is `false`, `includes/network/bootstrap.php` loads **none** of the network module — no keypair, no identity or list route, no registry, no Network window, no hop token minted or spent — a single site has no site switcher, and a multisite keeps the one it has on its own. Pairings already made survive a disable. See [network.md](network.md). diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index ae660140..54c6ca06 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -2186,7 +2186,7 @@ Heartbeat payloads and timestamp units are unchanged. See The **work area** is the rectangle of the desktop area (`#os-area`) that no shell chrome floats over — the space content may occupy and the user can reach. The bottom dock pill floats over the area's lower band; a window, widget or icon grid that sized itself against the whole area ended up with its last row under the dock, and every surface that guessed at the dock on its own guessed differently. The shell measures it once, from the live dock geometry, and every **default** placement reads it: window open (a registered size taller than the reachable height is fitted), session restore, cascade and tile, child windows, the widget clamp, the icon grid, sticky notes, embed-window restore, and the Fit of the Corkboard, the categories mind map and the tags cloud. -**Maximize and snap respect the dock safe area.** They fill the measured work area (or half its width) and reflow when dock size, placement or behavior changes, keeping window content and bottom actions above a Static dock. The `dynamic` [dock behavior](#getossettings--stable) folds the rail into a thin indicator and claims no band, so maximized windows reclaim that space; revealing a Dynamic rail remains a transient overlay. Manually dragged or resized windows keep their chosen placement. Fullscreen still fills the screen. +**Maximize and snap respect the dock safe area.** They fill the measured work area (or half its width) and reflow when dock size, placement or behavior changes, keeping window content and bottom actions above a Static dock. The `dynamic` [dock behavior](#getossettings--stable) folds the rail into a thin indicator and claims no band, so maximized windows reclaim that space; revealing a Dynamic rail remains a transient overlay. Manually dragged or resized windows keep their chosen placement — except inside a [reserved band](#reserved-bands), which no window may enter. Fullscreen still fills the screen. ```javascript // The latest snapshot (a copy). @@ -2208,11 +2208,27 @@ const inset = wp.os.workArea.insetsOf( myCanvasHost ); // React to changes — fires once per actual change, never on a // same-numbers re-measure. const off = wp.os.workArea.subscribe( ( snapshot ) => relayout( snapshot.rect ) ); + +// Reserve a band nothing else may use (see "Reserved bands" below). +const release = wp.os.workArea.reserve( 'my-plugin/ticker', { edge: 'top', size: 48 } ); +const band = wp.os.workArea.reservedRectOf( 'my-plugin/ticker' ); // viewport px, or null +const placeable = wp.os.workArea.placementRectOf(); // area minus reserved bands +release(); ``` **CSS custom properties.** The same numbers are written on `#os-shell` so a stylesheet can reserve the band without JS: `--os-work-area-inset-top`, `--os-work-area-inset-right`, `--os-work-area-inset-bottom`, `--os-work-area-inset-left` (px) and `--os-work-area-width`, `--os-work-area-height`. Give the `bottom` inset an `80px` fallback (the bottom pill at its default size, the placement almost every user has) and the others `0px`; those apply until the shell has measured once. `.os-area`'s own padding, the `.os-icons` grid and the `.os-widgets` column read them the same way. -**What claims an inset.** Only chrome that floats **over** the area: the bottom dock pill, and anything a custom dock-rail renderer floats over it (every `.os-dock` in the shell body is measured; a rail claims the edge nearest its centre). A left or right dock is a flex sibling of the area, so the area is already narrower and its inset is 0. The admin bar sits above the shell in every mode, so `viewport` is already below it — below where the bar *actually ends*, not where Core says it should: the shell measures `#wpadminbar` and publishes its bottom edge in viewport px as **`--os-admin-bar-height`** on `` (`src/admin-bar-height.ts`), and `.os-shell`, the toast stack and the release card all read `var( --os-admin-bar-height, var( --wp-admin--admin-bar--height, 32px ) )`. Core's token is Core's promise about Core's bar; a host that makes the bar taller, gives it a border or pushes it down under a fixed strip of its own (WordPress.com's staff debug chrome does) moves the measured edge and the shell follows. The property is present only while the bar is laid out at the top edge — a bar hidden by a mode, a mobile viewport, solo or a fullscreen window, or parked above the viewport in the `dynamic` mode, publishes nothing, so those states keep resolving Core's token. Chrome of your own that hangs below the bar should read the same chain. There is no API for a plugin to claim a band, on purpose: a work area is only useful while few things carve it. +**What claims an inset.** Only chrome that floats **over** the area: the bottom dock pill, and anything a custom dock-rail renderer floats over it (every `.os-dock` in the shell body is measured; a rail claims the edge nearest its centre). A left or right dock is a flex sibling of the area, so the area is already narrower and its inset is 0. The admin bar sits above the shell in every mode, so `viewport` is already below it — below where the bar *actually ends*, not where Core says it should: the shell measures `#wpadminbar` and publishes its bottom edge in viewport px as **`--os-admin-bar-height`** on `` (`src/admin-bar-height.ts`), and `.os-shell`, the toast stack and the release card all read `var( --os-admin-bar-height, var( --wp-admin--admin-bar--height, 32px ) )`. Core's token is Core's promise about Core's bar; a host that makes the bar taller, gives it a border or pushes it down under a fixed strip of its own (WordPress.com's staff debug chrome does) moves the measured edge and the shell follows. The property is present only while the bar is laid out at the top edge — a bar hidden by a mode, a mobile viewport, solo or a fullscreen window, or parked above the viewport in the `dynamic` mode, publishes nothing, so those states keep resolving Core's token. Chrome of your own that hangs below the bar should read the same chain. + +#### Reserved bands + +`wp.os.workArea.reserve( id, { edge, size } )` reserves a band along one edge (`top`, `right`, `bottom`, `left`, `size` in px) for a surface that must never be covered — the Pulse Monitor's [reserved top band](./pulse-monitor.md#layout) is the built-in user. It is stronger than a dock band: + +- It is added to the work-area insets (stacking after any dock band on that edge, then in reservation order), so maximize, snap, new-window placement, the wallpaper's file icons, widgets and notes all stay out of it, and the CSS custom properties include it. The wallpaper's file-icon layer starts at the work area's top-left (`--os-work-area-inset-top` / `-left`), and a sticky note stored inside the band is drawn just outside it — display only, so releasing the band puts it back. +- It also bounds **manual** placement. A window may be dragged under the dock, but not into a reserved band: dragging and resizing stop at its edge, a floating window already there is moved out when the band appears, and a window opened with explicit coordinates inside it (a native window's default `0, 0`) is placed just below it. `wp.os.workArea.placementRectOf( areaEl? )` is that bound: the desktop area minus reserved bands only. +- Reserving the same id again resizes the band in place; the returned function releases it (by id, whatever size it was last given). `wp.os.workArea.reservedRectOf( id )` gives the band's rectangle in viewport px, so the surface can draw into it — position your element there and follow `subscribe()`. + +The shell does not draw anything in a band; the reserver owns that space and should paint it (a layer appended to `#os-shell` sits above the desk area and under the docks). Reserve sparingly: every band takes room from every window, and a work area is only useful while few things carve it — this is for a surface the user asked to keep in view, not for placing a plugin's chrome. **Outside the contract.** Body-level popovers — context menus, tooltips, the dock's constellation and peek cards — position against the viewport and may open over the dock; they are transient chrome, not content, and stay that way. The Exposé overview collapses every rail while it is open and lays its grid out against the whole area on purpose. @@ -4775,7 +4791,7 @@ Posted from inside the chromeless iframe's `error` / `unhandledrejection` handle ``` #### `os-iframe-network` — Stable -Posted by the chromeless bridge's `fetch` and `XMLHttpRequest` wrappers whenever an HTTP call completes (success or failure). The parent re-dispatches as `HOOKS.IFRAME_NETWORK_COMPLETED` with `{ windowId, method, url, status, duration, failed }`. `status === 0` indicates a network-level failure before a response arrived. +Posted by the chromeless bridge's `fetch` and `XMLHttpRequest` wrappers whenever an HTTP call completes (success or failure). The parent re-dispatches as `HOOKS.IFRAME_NETWORK_COMPLETED` with `{ windowId, method, url, status, duration, failed, aborted }`. `status === 0` indicates a network-level failure before a response arrived, unless `aborted` is set: the page cancelled the request itself (an `AbortError` from `fetch`, an `abort` before `loadend` on an XHR). ```typescript { @@ -4785,6 +4801,7 @@ Posted by the chromeless bridge's `fetch` and `XMLHttpRequest` wrappers whenever status: number; duration: number; failed: boolean; + aborted?: true; // present only for a request the page cancelled } ``` @@ -5491,7 +5508,7 @@ Lifecycle + instrumentation for the chromeless iframe inside each window. Re-dis |---|---|---|---| | `os.iframe.ready` | action | Stable | `{ windowId }` — fires once per iframe when the chromeless bridge script has attached its listeners. Use this instead of the iframe's `load` event when timing matters (the native `load` fires before our bridge attaches, so messages sent on `load` can miss the listener). | | `os.iframe.error` | action | Stable | `{ windowId, kind: 'error' \| 'unhandledrejection', message, filename, lineno, colno, stack }` — bridged from the iframe's `error` / `unhandledrejection` handlers, for an iframe window's page and for an admin page embedded in a native window (`iframeContent`, `embedAdminPage()`); `windowId` is the owning window. Cross-origin iframe errors are origin-filtered at the bridge and never reach this hook. | -| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed }` — every `fetch` + `XMLHttpRequest` call inside the iframe: an iframe window's page, or an admin page embedded in a native window (`iframeContent`, `embedAdminPage()` — the Posts app's block editor included); `windowId` is the owning window. `status === 0` indicates a network-level failure with no response received. | +| `os.iframe.network-completed` | action | Stable | `{ windowId, method, url, status, duration, failed, aborted }` — every `fetch` + `XMLHttpRequest` call inside the iframe: an iframe window's page, or an admin page embedded in a native window (`iframeContent`, `embedAdminPage()` — the Posts app's block editor included); `windowId` is the owning window. `status === 0` indicates a network-level failure with no response received — unless `aborted` is `true`, which means the page cancelled the request itself (an `AbortController` signal, `xhr.abort()`), as a search-as-you-type field does with its previous query. | Use `IFRAME_READY` when you need to send a `os-focus` (or any parent→iframe message) as early as possible without racing the bridge setup. Use `IFRAME_ERROR` / `IFRAME_NETWORK_COMPLETED` to build a monitor widget that surfaces per-window reliability data. @@ -5945,6 +5962,19 @@ Declare `submenu` as a getter if the rows depend on live state — it is read fr --- +### Pulse Monitor — beats in and out *(Experimental)* + +The built-in Pulse Monitor — wallpaper `wp-pulse` and widget `desktop-mode/pulse`, one bundle — beats for every request and shell signal. See [Pulse Monitor](./pulse-monitor.md). + +- **Activity channel `pulse/beat`** — publish to add a beat. Payload `{ source?: string, amplitude?: number, error?: boolean, detail?: string }`. A known source id (`requests`, `visitors`, …) borrows that source's shape and label; anything else is a standard beat labelled with the id. `amplitude` is clamped to 0–2; on a failed beat, `detail` is printed after the label ("my-plugin · quota exceeded"). +- **Action `os.pulse.beat`** — fires for every beat a monitor draws. Payload `{ source, shape, error, major, duration, detail, ectopic, surface }`; `source` is `rest` for the idle pacemaker, `ectopic` marks a beat the diagnosis made abnormal, `surface` is `wallpaper` or `widget`. +- **Heartbeat field `openstation_pulse`** — sent while a monitor is mounted (`{ since }`); answered with `{ seq, now, events, health }`. + +```js +wp.os.activity.publish( 'pulse/beat', { source: 'my-plugin', error: true, detail: 'quota exceeded' } ); +wp.hooks.addAction( 'os.pulse.beat', 'my-plugin', ( beat ) => console.log( beat.source, beat.detail ) ); +``` + ### Wallpaper surfaces Collision-aware wallpapers (snow, rain, leaves, particle effects) need to know where things can "land" — window tops, the desktop floor, each dock's desktop-facing edge, widget cards. Rather than having every wallpaper hand-query the shell's DOM + hope the class names don't move, the shell emits a live surface list through `wp.os.getWallpaperSurfaces()`. diff --git a/docs/pulse-monitor.md b/docs/pulse-monitor.md new file mode 100644 index 00000000..969c09fa --- /dev/null +++ b/docs/pulse-monitor.md @@ -0,0 +1,194 @@ +# Pulse Monitor + +*Experimental.* A heart monitor wired to the site, as a built-in PixiJS canvas **wallpaper** (`wp-pulse`) and a **widget** (`desktop-mode/pulse`). Every HTTP request and every signal the desk can see is a labelled beat on a red ECG trace over black, failed beats say why, and the rhythm doubles as a health diagnosis. + +- Source: `src/plugins/pulse-monitor/` — one bundle for both faces (`assets/js/pulse-monitor[.min].js`, handle `os-pulse-monitor`) +- Server feed, script handle, widget registration: `includes/pulse-monitor.php` +- Wallpaper registration: `includes/wallpapers.php` + +## Two faces, one engine + +The wallpaper and the widget ship in the same bundle. The shell's loaders de-duplicate scripts by URL, so the engine loads once whichever face is on the desk. + +- **One hub per page.** `hub.ts` wires the signal taps, the Heartbeat feed and the health diagnosis once, reference-counted, however many monitors are mounted. Two faces never mean two `PerformanceObserver`s or two contributors to the Heartbeat field. +- **One monitor composition.** `monitor.ts` is what both faces mount: its own rhythm and Pixi scene, subscribed to the hub, plus the numbers, the spike labels and the beeper. The faces differ only in the scene variant. The widget's trace has taller beats, a slower sweep so labels stay readable, at most three echo lines, and the numbers in small type along its bottom edge. +- **Separate settings, one form.** The wallpaper's settings are the `wp-pulse` wallpaper settings, edited from *OS Settings → Wallpaper → Wallpaper settings*. The widget keeps its own, in its per-widget store (`ctx.storage`, this browser), edited from its gear button — so the widget can look and listen differently from the wallpaper. Both use the same form (`config.ts`); the widget's leaves out the wallpaper-only layout. Muting is applied per monitor, and the diagnosis is shared. + +| File | Role | +|---|---| +| `index.ts` | bundle entry: publishes both faces | +| `wallpaper.ts` / `widget.ts` | the two faces | +| `monitor.ts` | one mounted trace | +| `hub.ts`, `signals.ts` | the page's signal taps and Heartbeat feed | +| `health.ts`, `rhythm.ts` | diagnosis, beat scheduling and waveforms | +| `scene.ts`, `readout.ts` | the Pixi trace and labels, the numbers and beeper | +| `incidents.ts`, `issues.ts` | every failure with its URL and window; the Issues window | +| `config.ts`, `settings.ts`, `sources.ts` | the settings form, sanitizing, the signal catalog | + +## What makes it beat + +Each beat comes from a **source**. Users switch sources on and off in *OS Settings → Wallpaper → Wallpaper settings → Listening to*. Muting a source quiets the trace; it never blinds the diagnosis. + +| Source | Where it comes from | Spike | +|---|---|---| +| `requests` | `os/request-settled` (every `wp.os.fetch`), `os.iframe.network-completed` (fetch/XHR inside every window's page, the block editor embedded in the Posts app included), resource timing for window pages without the bridge (a 404, a fatal error), and `PerformanceObserver` resource timing in the shell for raw fetch/XHR | full beat | +| `pages` | `os.window.content-loaded`, plus every window iframe's `load` (in-window links, form POSTs, redirects), with the page's own HTTP status when same-origin | full beat | +| `heartbeat` | WordPress Heartbeat `heartbeat-tick` | double beat ("lub-dub") | +| `assets` | resource timing in the shell and every same-origin window iframe (scripts, styles, images, fonts) | tick | +| `windows` | window lifecycle, drags, snaps, overview, desktops, widgets, commands, desktop icons | short spike | +| `input` | clicks, key presses and wheel in the shell and same-origin windows | tick | +| `messages` | `postMessage` into the shell, `wp.os.broadcast`, connection messages | tick | +| `notices` | `os/toast-shown`, `os/notification-shown`, badge changes | short spike | +| `uploads` | file-drop uploads | full beat | +| `errors` | JS errors and rejections (shell, windows, commands), failed resources, auth loss, going offline | ectopic beat | +| `visitors`, `bots`, `comments`, `publish`, `orders`, `logins`, `api`, `cron`, `team` | the server feed (below) | full beat / short spike | + +Any request that fails (status ≥ 400 or a network error) draws as an ectopic beat in the failed-beat colour. A request the page cancelled itself (an `AbortController`, `xhr.abort()`: a search-as-you-type field dropping its previous query) is neither: it does not beat and does not count towards the diagnosis. + +Resting beats are deliberately small. With no activity, a pacemaker fires at the *Resting heart rate* setting. Every activity beat resets that clock and stands well above it. Bursts queue behind a 300 ms refractory gap: a page load reads as a run of tall, fast beats, and a queue that overflows makes the next beats taller instead of growing forever. + +Every full beat and short spike carries a small label naming its cause ("request", "heartbeat", "visit", "comment"…). Labels travel and fade with the trace, and repeats merge into "request ×3". Ticks (clicks, assets, messages) are not labelled unless they failed. + +### Amber beats + +Beats in the failed-beat colour (amber by default) failed, and their label says why: + +| Label | Meaning | +|---|---| +| `request · 404`, `page load · 500` | the HTTP status | +| `request · Failed to fetch`, `heartbeat · timeout` | a network-level failure: no response at all | +| `error · TypeError: x is undefined` | a JavaScript error (shell, a window, a command) | +| `error · img failed to load` | a broken resource | +| `upload · upload failed`, `error · session expired`, `error · offline` | | +| `visit · 500`, `login · failed` | a site-wide event that failed on the server | +| `PVC: slow responses (p95 3.1 s)` | a strained site: the diagnosis turned a resting beat ectopic, and names its leading cause | +| `request · VT: 42% of requests failing` | a critical site: every beat is ventricular tachycardia | + +**Background tabs.** A hidden tab is throttled: the browser delays timers and callbacks. Latency samples taken while the document is hidden are therefore discarded, so a backgrounded tab does not diagnose itself "slow" and fill the trace with PVCs. While the tab is hidden (or the wallpaper is suspended) the trace is frozen: beats are dropped rather than queued, so coming back does not replay the pause as one burst, and the numbers and the flatline alarm pause with it. Failures in the background still count towards the diagnosis, so a site that broke while you were away is drawn sick when you return. + +## Layout + +*Wallpaper layout → Trace area* in the settings picks where the wallpaper's trace lives: + +- **Whole wallpaper** (default). The trace runs across the whole wallpaper, under the desk. Windows can cover it, and the numbers sit at the bottom-right of the work area, clear of a docked pill. +- **Reserved top band**. The shell reserves a strip along the top of the desktop (*Band height*, 56–300 px, 88 by default) through [`wp.os.workArea.reserve()`](./javascript-reference.md#reserved-bands). Nothing else may be placed there: maximize, snap, new windows, icons and widgets stay below it, and dragging or resizing a window stops at its edge. The trace and its numbers draw on a layer over the band, the rest of the wallpaper is the plain background colour, and since nothing can cover the numbers, clicking them opens the [Issues window](#issues). + +On a phone (the shell's mobile mode, `wp.os.mode.isMobile()`), the band is not used: apps open full-screen there, so a reserved strip would only take room from them. The wallpaper falls back to the whole-wallpaper layout and reserves nothing, and switches back when the shell leaves mobile mode (a rotation, a wider window). + +The widget always fills its card. + +Resizing the browser keeps the trace. In sweep mode columns stay where they were drawn (a narrower window drops what fell off the edge); in scroll mode the newest columns are kept. Switching between sweep and scroll starts the trace over. + +## Health diagnosis + +The rhythm reflects site health over a sliding one-minute window: + +| Condition | Rhythm | Typical cause | +|---|---|---| +| healthy | regular sinus rhythm | — | +| strained | sinus with PVCs and ST elevation | a few failures, slow responses, Site Health issues | +| sick | atrial fibrillation: irregular, no P waves, quivering baseline | sustained failures or very slow responses | +| critical | ventricular tachycardia, drawn in the failed-beat colour | a large share of requests failing (≈ 30 %+), a 5xx storm | +| down | asystole (flatline), alarm tone when sound is on | offline, Heartbeat connection lost, five network failures in a row | + +The score combines: + +- failed requests (5xx and network failures fully, 4xx at a quarter weight) +- p95 latency above 800 ms, once the window holds at least 20 timed requests (so a single long call — an AI search, an upload, a streamed response — cannot set it on its own); each request counts for at most 10 s +- JavaScript errors per minute +- an expired session +- the server snapshot: Site Health criticals and recommendations, the 5xx share of site-wide traffic, and overdue cron + +What kind of trouble it is caps how bad the rhythm can get, whatever the score. Misconfiguration alone (Site Health, overdue cron) tops out at **strained**. Adding slowness or JavaScript errors tops out at **sick**. Only failing requests (or an expired session) reach **critical**, so a slow local site never draws ventricular tachycardia. + +The score is smoothed, quick to worsen and slow to recover, so one failed request is a skipped beat, not a rhythm change. Turn *Diagnose site health* off to keep a sinus rhythm regardless. + +The numbers are measured values only. Hover one for what it means: + +- **req/min** — requests this browser saw finish in the last minute. +- **ms** (with a clock icon) — the **median response time** over the last minute: half the requests were faster, half slower. It is the whole round trip (PHP, the database, the network), not a network ping, so 500–900 ms is normal on a heavy site. Only requests that finished with the tab visible count. The diagnosis looks at the slow end separately (p95, see above). +- **errors** — the share of those requests that failed: a 4xx or 5xx answer, or none at all. +- **visits/min** — front-end page views across the whole site, from the server feed; `--` when the feed does not reach this user. + +## Issues + +The Issues window explains the trace. Open it by clicking the numbers (in the widget card or the reserved band), with *Explore issues…* at the top of the settings form, or with **Pulse Monitor: issues** in the command palette (the way in when the trace is the full-screen wallpaper, under the desk). It shows: + +- **The score and the rhythm**, live. +- **Why**: each item holding the score down, with the points it costs, what it means, and — for Site Health issues and overdue cron — a button that opens Site Health in a window. +- **Recent problems**: every failure this page saw, newest first, up to 200 — failed requests with method, URL, status and the window they came from; JavaScript errors with file and line; broken resources; Heartbeat errors and lost connections; uploads; session expiry; going offline; and site-wide failures from the server feed. *Clear* empties the list. + +Everything listed was seen by this browser, about this browser's own traffic, and stays in the page. Site-wide events from the server feed carry a kind and a status only — the feed never relays URLs or IPs. While open, the window keeps the signal taps running, so it works with no monitor on the desk. + +## Server feed + +`includes/pulse-monitor.php` records requests the shell cannot see and relays them over Heartbeat. + +- **Cost.** Nothing is recorded unless a shell is listening. A mounted wallpaper contributes `openstation_pulse` to every Heartbeat tick, and each tick refreshes a three-minute lease in the autoloaded `openstation_pulse_listeners` option. With no live lease, a request pays one in-memory option read at `shutdown`. While any lease is live, **every** recorded request (front-end page views by every visitor included) does one `update_option` on the non-autoloaded `openstation_pulse_log` (capped at 200 rows / 2 minutes). On a busy site, an admin who leaves the wallpaper on means one `wp_options` UPDATE per page view, all on the same row; return `''` from `openstation_pulse_request_kind` to keep a class of traffic off the monitor. A listener's own requests and WP-CLI commands are never recorded. +- **Best effort.** The log and the leases are read-modify-write options with no lock. Under concurrent traffic some rows overwrite each other and those beats are lost; a lost lease write is redone on the next tick past its half-life. Fine for a live trace; not an audit log. +- **Classification.** Cron → `cron`. REST, XML-RPC, admin-ajax and admin pages → `team` when logged in, `api` when not. Front-end → `visitors`, or `bots` when the user agent looks automated. Explicit events replace the classification: a new comment, review, pingback or trackback → `comments` (other comment types, such as WooCommerce order notes, are bookkeeping and do not beat; `wp_insert_comment` also fires for imports, so an importer beats once per comment), a post going live → `publish`, `wp_login` → `logins`, `wp_login_failed` → `logins` (failed), `woocommerce_new_order` → `orders`. +- **Privacy.** Rows carry a kind, a timestamp, an HTTP status, an error flag and a user id. The user id is used only to exclude a listener's own traffic and never leaves the server. No URLs, IPs, user agents or names are stored. +- **Delivery.** The first tick returns only the cursor, so mounting does not replay history. Later ticks return the newer events plus a health snapshot (`{ siteHealth, requests, failures, cronLate }`). A cursor ahead of the log means the log was reset; that tick relays everything the log holds and brings the cursor back down. The client replays each batch at its real spacing, one tick late. While mounted, the wallpaper raises a slower Heartbeat interval to 15 s and restores it on unmount. +- **Access.** The feed requires `manage_options` by default (filter: `openstation_pulse_capability`). Users without it still get a live trace from their own shell. + +### PHP API + +```php +// Add a beat from your plugin, e.g. when a form is submitted. +openstation_pulse_record( 'comments' ); +openstation_pulse_record( 'my-plugin-refund', array( 'error' => true ) ); +``` + +Kinds are slug-sanitized; unknown kinds render as a standard beat labelled with the kind. + +| Hook | Type | Purpose | +|---|---|---| +| `openstation_pulse_capability` | filter | capability required to receive the site-wide feed (default `manage_options`) | +| `openstation_pulse_request_kind` | filter | rename a request's kind, or return `''` to keep it off the monitor | +| `openstation_pulse_is_bot` | filter | override bot detection for a user agent | +| `openstation_pulse_event` | filter | edit or drop (`false`) an event before it is logged | +| `openstation_pulse_health` | filter | extend the health snapshot (e.g. add failures from an uptime monitor) | + +Signatures are in the [Hooks Reference](./hooks-reference.md#pulse-monitor-filters--experimental). + +## JavaScript API + +```js +// Beat from anywhere in the shell — a plugin's own event. +wp.os.activity.publish( 'pulse/beat', { source: 'my-plugin', amplitude: 1 } ); +// A failed one, with the reason printed on the spike. +wp.os.activity.publish( 'pulse/beat', { source: 'my-plugin', error: true, detail: 'quota exceeded' } ); + +// React to every beat a monitor draws. +wp.hooks.addAction( 'os.pulse.beat', 'my-plugin', ( beat ) => { + // beat = { source, shape, error, major, duration, detail, ectopic, surface } + // surface: 'wallpaper' | 'widget' — with both on the desk, each draws (and reports) every beat. +} ); +``` + +`source` picks the shape and label: a known source id behaves like that source, anything else is a standard beat labelled with the id. `amplitude` is clamped to 0–2; `detail` is trimmed to 48 characters. `ectopic` is `true` when the diagnosis, not the signal, made the beat abnormal (a PVC or VT). + +## Settings + +The wallpaper's are persisted through the wallpaper settings surface (`ctx.settings`) under `wp-pulse`; the widget's in its own store (`os.widget.desktop-mode/pulse.settings` in `localStorage`), with the same keys and defaults (`layout` and `bandHeight` do not apply to it). The shell round-trips scalars only and caps a wallpaper at 32 keys, so muted sources travel as one comma-separated `muted` string. + +| Key | Default | | +|---|---|---| +| `layout` | `full` | wallpaper only: `full` (whole wallpaper) or `band` (a [reserved top band](#layout)) | +| `bandHeight` | 88 | height of the reserved band, px (56–300) | +| `style` | `sweep` | `sweep` (head crosses and wraps) or `scroll` (trace flows left) | +| `lineColor` / `background` / `errorColor` | `#ff2a2a` / `#000000` / `#ffb020` | | +| `speed` | 220 | px per second | +| `intensity` | 1 | beat height multiplier | +| `lineWidth`, `glow` | 2, 0.6 | | +| `baseline` | 58 | line height, % from the top | +| `echoes` | 10 | faint ghost traces above and below | +| `grid` | off | ECG paper grid | +| `readout` | on | the numbers | +| `labels` | on | name each spike | +| `sound` | off | beep per full beat; alarm on flatline. No audio context exists while it is off | +| `restingBpm` | 62 | 0 = flatline when quiet | +| `diagnose` | on | read site health into the rhythm | +| `muted` | `''` | comma-separated source ids | + +`prefers-reduced-motion` renders one still frame. diff --git a/includes/pulse-monitor.php b/includes/pulse-monitor.php new file mode 100644 index 00000000..89cfe855 --- /dev/null +++ b/includes/pulse-monitor.php @@ -0,0 +1,631 @@ + unix expiry`. + * Autoloaded: it is read on every request's `shutdown`. + */ +const OPENSTATION_PULSE_LISTENERS_OPTION = 'openstation_pulse_listeners'; + +/** + * Seconds a Heartbeat tick keeps a listener's lease alive. Four + * 60-second ticks' worth would be generous; three is enough for the + * slowest standard interval with one missed tick. + */ +const OPENSTATION_PULSE_LEASE_TTL = 180; + +/** + * Maximum retained log rows. + */ +const OPENSTATION_PULSE_LOG_MAX = 200; + +/** + * Milliseconds of history retained in the log. + */ +const OPENSTATION_PULSE_LOG_WINDOW_MS = 120000; + +/** + * Event kinds the server records. Mirrors the server-side source ids + * in `src/plugins/pulse-monitor/sources.ts`. + * + * @return string[] + */ +function openstation_pulse_kinds() { + return array( 'visitors', 'bots', 'comments', 'publish', 'orders', 'logins', 'api', 'cron', 'team' ); +} + +/** + * Per-request state: explicit events recorded by the content hooks + * during this request, flushed on `shutdown`. + * + * @return array Per-request state, by reference. + */ +function &openstation_pulse_state() { + static $state = array( 'events' => array() ); + return $state; +} + +/** + * Clears the per-request state. Used by tests. + */ +function openstation_pulse_reset() { + $state = &openstation_pulse_state(); + $state['events'] = array(); +} + +/** + * Returns the live listener leases, pruned of expired entries. + * + * @return array User id => unix expiry. + */ +function openstation_pulse_listeners() { + $raw = get_option( OPENSTATION_PULSE_LISTENERS_OPTION, array() ); + if ( ! is_array( $raw ) ) { + return array(); + } + $now = time(); + $live = array(); + foreach ( $raw as $user_id => $until ) { + if ( (int) $until > $now ) { + $live[ (int) $user_id ] = (int) $until; + } + } + return $live; +} + +/** + * Whether at least one shell is listening right now. + * + * @return bool + */ +function openstation_pulse_is_listening() { + return ! empty( openstation_pulse_listeners() ); +} + +/** + * Extends a user's listener lease. Writes only when the lease is new + * or past its half-life, so a 15-second Heartbeat costs one write + * every ~90 seconds rather than one per tick. + * + * @param int $user_id User id. + * @return bool Whether the option was written. + */ +function openstation_pulse_touch_listener( $user_id ) { + $user_id = (int) $user_id; + if ( $user_id <= 0 ) { + return false; + } + $leases = openstation_pulse_listeners(); + $now = time(); + if ( isset( $leases[ $user_id ] ) && $leases[ $user_id ] - $now > OPENSTATION_PULSE_LEASE_TTL / 2 ) { + return false; + } + $leases[ $user_id ] = $now + OPENSTATION_PULSE_LEASE_TTL; + update_option( OPENSTATION_PULSE_LISTENERS_OPTION, $leases, true ); + return true; +} + +/** + * Records an explicit event for this request — a comment, a publish, + * a login. Explicit events replace the generic request classification + * on `shutdown`, so a comment POST beats once as a comment rather + * than twice. + * + * Public so plugins can add their own beats: a form plugin can call + * `openstation_pulse_record( 'comments' )` on submission, a shop on a + * refund. Unknown kinds are accepted when they are slug-shaped; the + * client renders them as a generic beat. + * + * @param string $kind Event kind (see {@see openstation_pulse_kinds()}). + * @param array $args { + * Optional. Event flags. + * + * @type bool $error Render the beat as an ectopic (failed) beat. + * } + * @return bool True when the event was queued. + */ +function openstation_pulse_record( $kind, $args = array() ) { + $kind = is_string( $kind ) ? sanitize_key( $kind ) : ''; + if ( '' === $kind ) { + return false; + } + $state = &openstation_pulse_state(); + $state['events'][] = array( + 'kind' => $kind, + 'error' => ! empty( $args['error'] ), + ); + return true; +} + +/** + * Whether a user agent looks like a crawler, monitor or script. + * + * @param string $user_agent Raw user agent. + * @return bool + */ +function openstation_pulse_is_bot( $user_agent ) { + $user_agent = (string) $user_agent; + $is_bot = '' === $user_agent + || (bool) preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|embedly|preview|monitor|uptime|curl|wget|python|httpclient|okhttp|go-http|headless|lighthouse|feedfetcher|scrap/i', $user_agent ); + + /** + * Filters whether the current request counts as a bot for the + * Pulse Monitor — bot beats render smaller than visitor beats. + * + * @param bool $is_bot Whether the user agent matched the bot pattern. + * @param string $user_agent The raw user agent. + */ + return (bool) apply_filters( 'openstation_pulse_is_bot', $is_bot, $user_agent ); +} + +/** + * Classifies the current request into one Pulse kind. + * + * @return string Event kind, or '' when the request is not worth a beat. + */ +function openstation_pulse_classify_request() { + $logged_in = get_current_user_id() > 0; + + if ( wp_doing_cron() ) { + $kind = 'cron'; + } elseif ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) ) { + $kind = $logged_in ? 'team' : 'api'; + } elseif ( wp_doing_ajax() || is_admin() ) { + $kind = $logged_in ? 'team' : 'api'; + } else { + $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; + $kind = openstation_pulse_is_bot( $user_agent ) ? 'bots' : 'visitors'; + } + + /** + * Filters the Pulse kind a request is classified as. Return an + * empty string to keep the request off the monitor. + * + * @param string $kind The classified kind. + */ + return (string) apply_filters( 'openstation_pulse_request_kind', $kind ); +} + +/** + * Reads the log: its sequence counter and the rows still inside the + * retention window. + * + * @param int $now Current time in milliseconds. + * @return array { + * @type int $seq Highest sequence number handed out. + * @type array $entries Rows with `ts` at or after `$now` minus the window, oldest first. + * } + */ +function openstation_pulse_read_log( $now ) { + $log = get_option( OPENSTATION_PULSE_LOG_OPTION, array() ); + $seq = ( is_array( $log ) && isset( $log['seq'] ) ) ? (int) $log['seq'] : 0; + $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array(); + $cutoff = $now - OPENSTATION_PULSE_LOG_WINDOW_MS; + + return array( + 'seq' => $seq, + 'entries' => array_values( + array_filter( + $entries, + function ( $entry ) use ( $cutoff ) { + return is_array( $entry ) && isset( $entry['ts'] ) && (int) $entry['ts'] >= $cutoff; + } + ) + ), + ); +} + +/** + * Appends rows to the log, pruning by age and count. + * + * @param array $rows Rows with `kind`, `status`, `error`, `user`. + * @param int $now Current time in milliseconds. + */ +function openstation_pulse_append( $rows, $now ) { + $log = openstation_pulse_read_log( $now ); + $seq = $log['seq']; + $entries = $log['entries']; + + foreach ( $rows as $row ) { + $entries[] = array( + 'seq' => ++$seq, + 'ts' => $now, + 'kind' => $row['kind'], + 'status' => (int) $row['status'], + 'error' => (bool) $row['error'], + 'user' => (int) $row['user'], + ); + } + + if ( count( $entries ) > OPENSTATION_PULSE_LOG_MAX ) { + $entries = array_slice( $entries, -OPENSTATION_PULSE_LOG_MAX ); + } + + update_option( + OPENSTATION_PULSE_LOG_OPTION, + array( + 'seq' => $seq, + 'entries' => $entries, + ), + false + ); +} + +/** + * `shutdown` handler — records this request when a shell is listening. + */ +function openstation_pulse_on_shutdown() { + $listeners = openstation_pulse_listeners(); + $state = &openstation_pulse_state(); + $explicit = $state['events']; + openstation_pulse_reset(); + + // A WP-CLI command is not site traffic — and with no user agent it + // would otherwise beat as a bot. + if ( empty( $listeners ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) { + return; + } + $user_id = get_current_user_id(); + if ( $user_id > 0 && isset( $listeners[ $user_id ] ) ) { + // The listener's own shell already beats for its requests — + // including the block editor's, which the chromeless bridge + // reports from inside the editor window. + return; + } + + $status = http_response_code(); + $status = is_int( $status ) ? $status : 200; + + if ( empty( $explicit ) ) { + $kind = openstation_pulse_classify_request(); + if ( '' === $kind ) { + return; + } + $explicit = array( + array( + 'kind' => $kind, + 'error' => false, + ), + ); + } + + $rows = array(); + foreach ( $explicit as $event ) { + $row = array( + 'kind' => $event['kind'], + 'status' => $status, + 'error' => $event['error'] || $status >= 500, + 'user' => $user_id, + ); + + /** + * Filters one Pulse Monitor event before it is logged. Return + * a falsy value to drop it. + * + * @param array|false $row { + * The event. + * + * @type string $kind Event kind. + * @type int $status HTTP status of the request. + * @type bool $error Whether it renders as a failed beat. + * @type int $user Current user id (0 for visitors). + * } + */ + $row = apply_filters( 'openstation_pulse_event', $row ); + if ( is_array( $row ) && isset( $row['kind'] ) && '' !== sanitize_key( (string) $row['kind'] ) ) { + $rows[] = array( + 'kind' => sanitize_key( (string) $row['kind'] ), + 'status' => isset( $row['status'] ) ? (int) $row['status'] : $status, + 'error' => ! empty( $row['error'] ), + 'user' => $user_id, + ); + } + } + + if ( ! empty( $rows ) ) { + openstation_pulse_append( $rows, (int) round( microtime( true ) * 1000 ) ); + } +} + +/** + * Builds the server-side health snapshot the trace diagnoses from. + * + * @param array $entries Log rows inside the retention window. + * @return array { + * @type array|null $siteHealth `{ good, recommended, critical }` from the + * last Site Health run, or null. + * @type int $requests Logged requests in the window. + * @type int $failures Logged requests that failed (5xx / flagged). + * @type int $cronLate Seconds the oldest due cron event is overdue. + * } + */ +function openstation_pulse_health_snapshot( $entries ) { + $site_health = null; + $cached = get_transient( 'health-check-site-status-result' ); + if ( is_string( $cached ) && '' !== $cached ) { + $decoded = json_decode( $cached, true ); + if ( is_array( $decoded ) ) { + $site_health = array( + 'good' => isset( $decoded['good'] ) ? (int) $decoded['good'] : 0, + 'recommended' => isset( $decoded['recommended'] ) ? (int) $decoded['recommended'] : 0, + 'critical' => isset( $decoded['critical'] ) ? (int) $decoded['critical'] : 0, + ); + } + } + + $failures = 0; + foreach ( $entries as $entry ) { + if ( ! empty( $entry['error'] ) ) { + ++$failures; + } + } + + $cron_late = 0; + $crons = function_exists( '_get_cron_array' ) ? _get_cron_array() : array(); + if ( is_array( $crons ) && ! empty( $crons ) ) { + $first = (int) min( array_keys( $crons ) ); + // A minute of slack: WP-Cron only runs on the next request. + $cron_late = max( 0, time() - $first - 60 ); + } + + $snapshot = array( + 'siteHealth' => $site_health, + 'requests' => count( $entries ), + 'failures' => $failures, + 'cronLate' => $cron_late, + ); + + /** + * Filters the health snapshot the Pulse Monitor diagnoses from. + * Plugins that know more (an uptime monitor, a queue depth) can + * add failures or override the cron delay. + * + * @param array $snapshot The snapshot. + */ + return (array) apply_filters( 'openstation_pulse_health', $snapshot ); +} + +/** + * Heartbeat handler — relays logged events newer than the client's + * cursor, plus the health snapshot. + * + * Opt-in via the client-sent `openstation_pulse` key; ticks without + * it return untouched. The first tick (cursor 0) returns only the + * cursor, so mounting the wallpaper does not replay two minutes of + * history as one burst. A cursor ahead of the log means the log was + * reset (deleted, restored, migrated): every row it holds is new to + * that client, so they are all relayed and the response brings the + * cursor back down. + * + * @param array $response Heartbeat response. + * @param array $data Client-sent payload. + * @return array + */ +function openstation_pulse_heartbeat_received( $response, $data ) { + if ( ! is_array( $response ) ) { + $response = array(); + } + if ( ! isset( $data['openstation_pulse'] ) ) { + return $response; + } + + /** + * Filters the capability required to receive the site-wide Pulse + * Monitor feed. Users without it still get a live trace from their + * own shell's traffic; they just do not see visitors, logins or + * other people's activity. + * + * @param string $capability Capability. Default 'manage_options'. + */ + $capability = (string) apply_filters( 'openstation_pulse_capability', 'manage_options' ); + if ( ! current_user_can( $capability ) ) { + return $response; + } + + $user_id = get_current_user_id(); + openstation_pulse_touch_listener( $user_id ); + + $payload = is_array( $data['openstation_pulse'] ) ? $data['openstation_pulse'] : array(); + $since = isset( $payload['since'] ) ? (int) $payload['since'] : 0; + + $now = (int) round( microtime( true ) * 1000 ); + $log = openstation_pulse_read_log( $now ); + $seq = $log['seq']; + $windowed = $log['entries']; + $replay = $since > 0; + if ( $since > $seq ) { + $since = 0; + } + + $fresh = array(); + if ( $replay ) { + foreach ( $windowed as $entry ) { + if ( (int) $entry['seq'] <= $since || (int) $entry['user'] === $user_id ) { + continue; + } + $fresh[] = array( + 'ts' => (int) $entry['ts'], + 'kind' => (string) $entry['kind'], + 'status' => (int) $entry['status'], + 'error' => ! empty( $entry['error'] ), + ); + } + } + + $response['openstation_pulse'] = array( + 'seq' => $seq, + 'now' => $now, + 'events' => $fresh, + 'health' => openstation_pulse_health_snapshot( $windowed ), + ); + + return $response; +} + +/** + * Comment hook — a new comment, review, pingback or trackback beats as + * a comment (spam included). Other comment types are bookkeeping that + * plugins keep in the comments table — WooCommerce order notes, for + * one — and stay off the trace. + * + * `wp_insert_comment` also fires for imports and programmatic + * inserts: an importer bringing in a thousand comments beats a + * thousand times. + * + * @param int $comment_id Comment id. + * @param WP_Comment|null $comment Comment. + */ +function openstation_pulse_on_comment( $comment_id, $comment = null ) { + $type = ( $comment instanceof WP_Comment ) ? $comment->comment_type : get_comment_type( $comment_id ); + if ( ! in_array( (string) $type, array( '', 'comment', 'review', 'pingback', 'trackback' ), true ) ) { + return; + } + openstation_pulse_record( 'comments' ); +} + +/** + * Post status hook — a post going live beats as a publish. + * + * @param string $new_status New status. + * @param string $old_status Old status. + * @param WP_Post $post Post. + */ +function openstation_pulse_on_transition_post_status( $new_status, $old_status, $post ) { + if ( 'publish' !== $new_status || 'publish' === $old_status ) { + return; + } + if ( ! $post instanceof WP_Post || ! is_post_type_viewable( $post->post_type ) ) { + return; + } + openstation_pulse_record( 'publish' ); +} + +/** + * Registers the bundle both faces share. One script for the wallpaper + * (`wp-pulse`, registered in `includes/wallpapers.php`) and the widget + * (`desktop-mode/pulse`, below): the shell's loaders de-duplicate by + * URL, so the engine, the signal taps and the Heartbeat feed load once + * whichever face is on the desk. `heartbeat` is a dependency because + * the monitor rides Heartbeat for site-wide events and for the + * Heartbeat beat itself. + */ +function openstation_pulse_register_assets() { + $suffix = openstation_asset_suffix(); + $version = defined( 'OPENSTATION_VERSION' ) ? OPENSTATION_VERSION : '0'; + $js_path = OPENSTATION_DIR . 'assets/js/pulse-monitor' . $suffix . '.js'; + + wp_register_script( + 'os-pulse-monitor', + OPENSTATION_URL . 'assets/js/pulse-monitor' . $suffix . '.js', + array( 'wp-hooks', 'wp-i18n', 'heartbeat' ), + file_exists( $js_path ) ? (string) filemtime( $js_path ) : $version, + true + ); + wp_set_script_translations( 'os-pulse-monitor', 'desktop-mode', OPENSTATION_DIR . 'languages' ); +} + +/** + * Registers the Pulse Monitor widget: the wallpaper's trace, numbers + * and labels in a resizable card, sharing its settings. + */ +function openstation_pulse_register_widget() { + if ( ! function_exists( 'openstation_register_widget' ) ) { + return; + } + openstation_register_widget( + 'desktop-mode/pulse', + array( + 'label' => __( 'Pulse Monitor', 'desktop-mode' ), + 'description' => __( 'A heart monitor for your site: every request, visitor, comment and Heartbeat tick is a labelled beat, failed ones say why, and the rhythm shows how healthy the site is.', 'desktop-mode' ), + 'icon' => 'dashicons-heart', + 'script' => 'os-pulse-monitor', + 'movable' => true, + 'resizable' => true, + 'min_width' => 260, + 'min_height' => 150, + 'default_width' => 380, + 'default_height' => 200, + ) + ); +} + +/** + * Wires the recorder. + */ +function openstation_pulse_register_hooks() { + add_action( 'wp_insert_comment', 'openstation_pulse_on_comment', 10, 2 ); + add_action( 'transition_post_status', 'openstation_pulse_on_transition_post_status', 10, 3 ); + add_action( + 'wp_login', + function () { + openstation_pulse_record( 'logins' ); + } + ); + add_action( + 'wp_login_failed', + function () { + openstation_pulse_record( 'logins', array( 'error' => true ) ); + } + ); + add_action( + 'woocommerce_new_order', + function () { + openstation_pulse_record( 'orders' ); + } + ); + add_action( 'shutdown', 'openstation_pulse_on_shutdown' ); + add_filter( 'heartbeat_received', 'openstation_pulse_heartbeat_received', 10, 2 ); +} +add_action( 'init', 'openstation_pulse_register_hooks', 5 ); +add_action( 'init', 'openstation_pulse_register_assets', 5 ); +add_action( 'init', 'openstation_pulse_register_widget', 6 ); diff --git a/includes/wallpapers.php b/includes/wallpapers.php index 8fdd3c93..ab01a885 100644 --- a/includes/wallpapers.php +++ b/includes/wallpapers.php @@ -171,5 +171,25 @@ function openstation_register_builtin_wallpapers() { 'description' => __( 'Snow falling over a midnight sky. Flakes settle on the top edge of every window, pile into little drifts, then quietly melt away. Open the wallpaper settings to tune the wind, the snowfall, the flake size, and the colour of the night.', 'desktop-mode' ), ) ); + + // Pulse Monitor — built-in PixiJS canvas wallpaper: an ECG trace + // that beats for every HTTP request and every signal the shell can + // see (window traffic, Heartbeat, clicks, errors), plus the + // site-wide events `includes/pulse-monitor.php` relays over + // Heartbeat (visitors, bots, comments, logins, cron, API). The + // rhythm doubles as a health diagnosis. The preview must match the + // default backdrop in `src/plugins/pulse-monitor/settings.ts`; the + // script handle (shared with the Pulse widget) is registered in + // `includes/pulse-monitor.php`. + openstation_register_wallpaper( + 'wp-pulse', + array( + 'label' => __( 'Pulse Monitor', 'desktop-mode' ), + 'preview' => '#000000', + 'type' => 'canvas', + 'script' => 'os-pulse-monitor', + 'description' => __( 'A heart monitor wired to your site. Every request, visitor, comment, login, Heartbeat tick and click is a beat. A healthy site keeps a steady sinus rhythm; errors, slow responses and outages show up as skipped beats, fibrillation, tachycardia or a flatline. Open the wallpaper settings to choose what it listens to, the colours, the sweep speed and the intensity.', 'desktop-mode' ), + ) + ); } add_action( 'init', 'openstation_register_builtin_wallpapers', 5 ); diff --git a/package.json b/package.json index 9eb60904..c8cd95b1 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ } }, "scripts": { - "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:chromeless-bridge && npm run build:gutenberg-drop-receiver && npm run build:my-wordpress-woocommerce && npm run build:content-graph && npm run build:app-runtime && npm run build:user-profile && npm run build:apps && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:shell-overlays && npm run build:file-drop && npm run build:files-overlays && npm run build:notes && npm run build:dock-constellation && npm run build:window-link-visuals && npm run build:components && npm run build:mio && npm run build:window-system && npm run build:mobile && npm run build:item-visibility-menu && npm run build:workspace-wizard && npm run build:release-card && npm run build:shell-tour && npm run build:deactivation-feedback && npm run build:usage-feedback && npm run build:games && npm run build:game-inkfall && npm run build:game-alphabet-soup && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:widget-focus-timer && npm run build:widget-drafts && npm run build:agent-run-window && npm run build:pwa-sw", + "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:chromeless-bridge && npm run build:gutenberg-drop-receiver && npm run build:my-wordpress-woocommerce && npm run build:content-graph && npm run build:app-runtime && npm run build:user-profile && npm run build:apps && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:pulse-monitor && npm run build:shell-overlays && npm run build:file-drop && npm run build:files-overlays && npm run build:notes && npm run build:dock-constellation && npm run build:window-link-visuals && npm run build:components && npm run build:mio && npm run build:window-system && npm run build:mobile && npm run build:item-visibility-menu && npm run build:workspace-wizard && npm run build:release-card && npm run build:shell-tour && npm run build:deactivation-feedback && npm run build:usage-feedback && npm run build:games && npm run build:game-inkfall && npm run build:game-alphabet-soup && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:widget-focus-timer && npm run build:widget-drafts && npm run build:agent-run-window && npm run build:pwa-sw", "build:desktop": "vite build --mode development && vite build --mode production", "build:iframe-bridge": "OPENSTATION_TARGET=iframe-bridge vite build --mode development && OPENSTATION_TARGET=iframe-bridge vite build --mode production", "build:chromeless-bridge": "OPENSTATION_TARGET=chromeless-bridge vite build --mode development && OPENSTATION_TARGET=chromeless-bridge vite build --mode production", @@ -46,6 +46,7 @@ "build:animated-logo-wallpaper": "OPENSTATION_TARGET=animated-logo-wallpaper vite build --mode development && OPENSTATION_TARGET=animated-logo-wallpaper vite build --mode production", "build:living-tree-wallpaper": "OPENSTATION_TARGET=living-tree-wallpaper vite build --mode development && OPENSTATION_TARGET=living-tree-wallpaper vite build --mode production", "build:snow-wallpaper": "OPENSTATION_TARGET=snow-wallpaper vite build --mode development && OPENSTATION_TARGET=snow-wallpaper vite build --mode production", + "build:pulse-monitor": "OPENSTATION_TARGET=pulse-monitor vite build --mode development && OPENSTATION_TARGET=pulse-monitor vite build --mode production", "build:shell-overlays": "OPENSTATION_TARGET=shell-overlays vite build --mode development && OPENSTATION_TARGET=shell-overlays vite build --mode production", "build:file-drop": "OPENSTATION_TARGET=file-drop vite build --mode development && OPENSTATION_TARGET=file-drop vite build --mode production", "build:files-overlays": "OPENSTATION_TARGET=files-overlays vite build --mode development && OPENSTATION_TARGET=files-overlays vite build --mode production", diff --git a/src/chromeless-bridge.js b/src/chromeless-bridge.js index ff3c1484..7353d37d 100644 --- a/src/chromeless-bridge.js +++ b/src/chromeless-bridge.js @@ -271,7 +271,9 @@ * - `fetch` + `XMLHttpRequest` are wrapped so every completed * request (including failures) posts * `os-iframe-network` with `{ method, url, status, - * duration, failed }`. Parent dispatches `HOOKS. + * duration, failed }`, plus `aborted: true` when the page + * cancelled the request itself (an `AbortController`, an + * `xhr.abort()`). Parent dispatches `HOOKS. * IFRAME_NETWORK_COMPLETED`. * * Privacy: request / response bodies are NEVER captured — only @@ -383,6 +385,11 @@ duration: typeof duration === 'number' ? duration : 0, failed: !! failed }; + // Cancelled by the page, not failed by the network: a + // search-as-you-type field aborting its last query. + if ( extra && extra.aborted ) { + msg.aborted = true; + } if ( extra && window.__wpdInstrument && window.__wpdInstrument.observe ) { if ( extra.requestHeaders ) { msg.requestHeaders = extra.requestHeaders; @@ -698,7 +705,12 @@ var dur = ( ( typeof performance !== 'undefined' && performance.now ) ? performance.now() : Date.now() ) - start; - osReportNetwork( method, url, 0, Math.round( dur ), true, requestHeaders ? { requestHeaders: requestHeaders } : null ); + var failExtra = requestHeaders ? { requestHeaders: requestHeaders } : null; + if ( err && err.name === 'AbortError' ) { + failExtra = failExtra || {}; + failExtra.aborted = true; + } + osReportNetwork( method, url, 0, Math.round( dur ), true, failExtra ); osActivityEnd( tracked, true, 0 ); throw err; } @@ -764,10 +776,16 @@ } } + // `loadend` follows `abort` too; remember which one it was. + var aborted = false; + var onAbort = function () { + aborted = true; + }; var fire = function () { // An uploader may open/send the same XHR again. Keep // this request's closure out of later completions. xhr.removeEventListener( 'loadend', fire ); + xhr.removeEventListener( 'abort', onAbort ); var dur = ( ( typeof performance !== 'undefined' && performance.now ) ? performance.now() : Date.now() ) - start; @@ -792,6 +810,10 @@ } catch ( _rErr ) { /* swallow */ } } var failed = xhr.status === 0 || xhr.status >= 400; + if ( aborted ) { + extra = extra || {}; + extra.aborted = true; + } osReportNetwork( xhr.__wpdMethod, xhr.__wpdUrl, @@ -804,6 +826,7 @@ osMaybeForceAuthCheck( xhr.status, xhr.__wpdUrl, background ); }; try { + xhr.addEventListener( 'abort', onAbort ); xhr.addEventListener( 'loadend', fire ); } catch ( _err ) { /* swallow */ } try { @@ -812,6 +835,7 @@ // A synchronous send failure has no loadend to remove // the listener or balance the activity-start message. xhr.removeEventListener( 'loadend', fire ); + xhr.removeEventListener( 'abort', onAbort ); osReportNetwork( xhr.__wpdMethod, xhr.__wpdUrl, 0, 0, true, null ); osActivityEnd( tracked, true, 0 ); throw sync; diff --git a/src/notes/layer.ts b/src/notes/layer.ts index 38101693..a17249b9 100644 --- a/src/notes/layer.ts +++ b/src/notes/layer.ts @@ -45,7 +45,7 @@ import { import { startNotesHeartbeat } from './heartbeat'; import { trashNoteWithUndo } from './trash'; import { convertNoteToPost } from './convert'; -import { workAreaRectOf } from '../work-area'; +import { getReservedInsets, subscribeWorkArea, workAreaRectOf } from '../work-area'; import { NOTE_PAYLOAD_TYPE, type Note, @@ -146,6 +146,13 @@ export class NotesLayer { this.upsertNote( note, { animate: 'none' } ); } ); startNotesHeartbeat( this ); + // A band reserved (or released) after the notes were placed + // moves them out of it, and back. + subscribeWorkArea( () => { + for ( const controller of this.controllers.values() ) { + controller.applyPosition(); + } + } ); } catch ( error ) { // A fresh site with zero notes still resolves fine — an // error here means the routes are unreachable. Stay quiet @@ -840,9 +847,20 @@ export class NoteController { }, Z_SAVE_DEBOUNCE_MS ); } + /** + * Paint the stored position. While a band is reserved + * (`reserveWorkArea()`), a note that would sit in it is drawn just + * outside instead — display only: the stored position is the + * user's, and releasing the band puts the note back where it was. + */ applyPosition(): void { - this.element.style.left = `${ ( this.note.x * 100 ).toFixed( 3 ) }%`; - this.element.style.top = `${ ( this.note.y * 100 ).toFixed( 3 ) }%`; + let { x, y } = this.note; + const reserved = getReservedInsets(); + if ( reserved.top > 0 || reserved.left > 0 ) { + ( { x, y } = this.layer.clampPosition( x, y ) ); + } + this.element.style.left = `${ ( x * 100 ).toFixed( 3 ) }%`; + this.element.style.top = `${ ( y * 100 ).toFixed( 3 ) }%`; } /** Optimistically move + persist. Used by drop handler and keyboard. */ diff --git a/src/plugins/pulse-monitor/config.ts b/src/plugins/pulse-monitor/config.ts new file mode 100644 index 00000000..d86e533e --- /dev/null +++ b/src/plugins/pulse-monitor/config.ts @@ -0,0 +1,251 @@ +/** + * Pulse Monitor — the settings form. + * + * One form, two doors: the wallpaper's "Wallpaper settings" dialog in + * OS Settings (`renderConfig`), and the widget's gear button. Each + * edits its own settings — the wallpaper's `wp-pulse` bag, the widget's + * per-widget store — through `ctx.setSettings`; `ctx.face: 'widget'` + * leaves out the wallpaper-only layout. + */ + +import { __ } from '../../i18n'; +import '../../ui/components/os-range-field/os-range-field'; +import '../../ui/components/os-color-field/os-color-field'; +import '../../ui/components/os-button/os-button'; +import '../../ui/components/os-switch/os-switch'; +import '../../ui/components/os-segmented/os-segmented'; +import { + PULSE_DEFAULTS, + PULSE_LIMITS, + sanitizePulseSettings, + serializeMuted, + type PulseSettings, +} from './settings'; +import { openIssues } from './issues'; +import { SOURCE_IDS, sourceCatalog, type SourceId } from './sources'; + +/** What the form needs: the current values and a way to write. */ +export interface PulseConfigContext { + settings: Record< string, unknown >; + setSettings( partial: Record< string, string | number | boolean > ): void; + /** Which face the form edits; the widget has no wallpaper layout. Default `wallpaper`. */ + face?: 'wallpaper' | 'widget'; +} + +// Colours come from the surface's tokens (`` and OS Settings +// both set `--os-ui-fg` / `--os-ui-fg-muted`). Never inherit: wp-admin's +// `h1–h6 { color: #1d2327 }` would win and paint the headings near-black +// on a dark dialog. +function heading( text: string ): HTMLElement { + const el = document.createElement( 'h3' ); + el.textContent = text; + el.style.margin = '12px 0 0'; + el.style.font = 'inherit'; + el.style.fontWeight = '600'; + el.style.color = 'var( --os-ui-fg, currentColor )'; + return el; +} + +function note( text: string ): HTMLElement { + const el = document.createElement( 'p' ); + el.textContent = text; + el.style.margin = '0'; + el.style.color = 'var( --os-ui-fg-muted, currentColor )'; + el.style.fontSize = '12px'; + return el; +} + +function rangeField( + label: string, + limits: { min: number; max: number }, + step: number, + value: number, + onChange: ( next: number ) => void, +): HTMLElement { + const field = document.createElement( 'os-range-field' ); + field.setAttribute( 'label', label ); + field.setAttribute( 'min', String( limits.min ) ); + field.setAttribute( 'max', String( limits.max ) ); + field.setAttribute( 'step', String( step ) ); + field.setAttribute( 'value', String( value ) ); + field.addEventListener( 'os-range-change', ( e: Event ) => { + onChange( ( e as CustomEvent< { value: number } > ).detail.value ); + } ); + return field; +} + +function colorField( label: string, value: string, onChange: ( next: string ) => void ): HTMLElement { + const field = document.createElement( 'os-color-field' ); + field.setAttribute( 'label', label ); + field.setAttribute( 'value', value ); + field.addEventListener( 'os-color-change', ( e: Event ) => { + onChange( ( e as CustomEvent< { value: string } > ).detail.value ); + } ); + return field; +} + +function switchField( + label: string, + checked: boolean, + onChange: ( next: boolean ) => void, + description = '', +): HTMLElement { + const field = document.createElement( 'os-switch' ); + field.setAttribute( 'label', label ); + field.setAttribute( 'block', '' ); + field.setAttribute( 'size', 'sm' ); + field.setAttribute( 'tone', 'accent' ); + if ( description ) { + field.setAttribute( 'description', description ); + } + if ( checked ) { + field.setAttribute( 'checked', '' ); + } + field.addEventListener( 'os-switch-change', ( e: Event ) => { + onChange( ( e as CustomEvent< { checked: boolean } > ).detail.checked ); + } ); + return field; +} + +function segmentedField( + label: string, + value: string, + options: Array< [ string, string ] >, + onChange: ( next: string ) => void, +): HTMLElement { + const field = document.createElement( 'os-segmented' ); + field.setAttribute( 'label', label ); + field.setAttribute( 'value', value ); + for ( const [ optionValue, optionLabel ] of options ) { + const option = document.createElement( 'os-segment' ); + option.setAttribute( 'value', optionValue ); + option.textContent = optionLabel; + field.appendChild( option ); + } + field.addEventListener( 'os-pick', ( e: Event ) => { + onChange( ( e as CustomEvent< { value: string } > ).detail.value ); + } ); + return field; +} + +/** + * The settings form. Every control writes through `ctx.setSettings` + * immediately and every mounted monitor live-applies, so the form + * doubles as a tuning panel. Rebuilt in place on reset. Returns its + * teardown (listeners die with the DOM, so it has nothing to do). + */ +export function renderPulseConfig( container: HTMLElement, ctx: PulseConfigContext ): () => void { + let current = sanitizePulseSettings( ctx.settings ); + + const set = ( partial: Partial< PulseSettings > ): void => { + current = { ...current, ...partial }; + const { muted, ...scalars } = partial; + ctx.setSettings( { + ...( scalars as Record< string, string | number | boolean > ), + ...( muted ? { muted: serializeMuted( muted ) } : {} ), + } ); + }; + + const build = (): void => { + container.replaceChildren(); + const add = ( ...els: HTMLElement[] ): void => { + for ( const el of els ) { + container.appendChild( el ); + } + }; + + const issues = document.createElement( 'os-button' ); + issues.setAttribute( 'variant', 'secondary' ); + issues.style.alignSelf = 'flex-start'; + issues.textContent = __( 'Explore issues…' ); + issues.addEventListener( 'click', openIssues ); + add( + issues, + note( __( 'What is holding the health score down, and every failed request, error and timeout with its URL and window.' ) ), + ); + + if ( ctx.face !== 'widget' ) { + add( heading( __( 'Wallpaper layout' ) ) ); + add( + segmentedField( __( 'Trace area' ), current.layout, [ + [ 'full', __( 'Whole wallpaper' ) ], + [ 'band', __( 'Reserved top band' ) ], + ], ( v ) => set( { layout: v === 'band' ? 'band' : 'full' } ) ), + rangeField( __( 'Band height' ), PULSE_LIMITS.bandHeight, 4, current.bandHeight, ( v ) => set( { bandHeight: Math.round( v ) } ) ), + note( __( 'A reserved band is kept for the trace and its numbers: windows, icons and widgets stay below it, even when moved by hand, and clicking the numbers opens the issues. Not used on phones.' ) ), + ); + } + + add( heading( __( 'Monitor' ) ) ); + add( + segmentedField( __( 'Trace' ), current.style, [ + [ 'sweep', __( 'Sweep' ) ], + [ 'scroll', __( 'Scroll' ) ], + ], ( v ) => set( { style: v === 'scroll' ? 'scroll' : 'sweep' } ) ), + colorField( __( 'Line color' ), current.lineColor, ( v ) => set( { lineColor: sanitizePulseSettings( { lineColor: v } ).lineColor } ) ), + colorField( __( 'Background color' ), current.background, ( v ) => set( { background: sanitizePulseSettings( { background: v } ).background } ) ), + colorField( __( 'Failed beat color' ), current.errorColor, ( v ) => set( { errorColor: sanitizePulseSettings( { errorColor: v } ).errorColor } ) ), + note( __( 'Beats in this colour failed, and their label says why: a status ("request · 404"), an error message, or — when the site’s health changes the rhythm — the rhythm and its cause ("PVC: slow responses").' ) ), + rangeField( __( 'Speed' ), PULSE_LIMITS.speed, 10, current.speed, ( v ) => set( { speed: v } ) ), + rangeField( __( 'Intensity' ), PULSE_LIMITS.intensity, 0.05, current.intensity, ( v ) => set( { intensity: v } ) ), + rangeField( __( 'Line width' ), PULSE_LIMITS.lineWidth, 0.5, current.lineWidth, ( v ) => set( { lineWidth: v } ) ), + rangeField( __( 'Glow' ), PULSE_LIMITS.glow, 0.05, current.glow, ( v ) => set( { glow: v } ) ), + rangeField( __( 'Line height' ), PULSE_LIMITS.baseline, 1, current.baseline, ( v ) => set( { baseline: v } ) ), + rangeField( __( 'Echo lines' ), PULSE_LIMITS.echoes, 1, current.echoes, ( v ) => set( { echoes: Math.round( v ) } ) ), + switchField( __( 'ECG paper grid' ), current.grid, ( v ) => set( { grid: v } ) ), + switchField( __( 'Numbers (requests/min, ping, errors, visits/min)' ), current.readout, ( v ) => set( { readout: v } ) ), + switchField( __( 'Name each spike (request, visit, comment…)' ), current.labels, ( v ) => set( { labels: v } ) ), + switchField( __( 'Beep on every beat' ), current.sound, ( v ) => set( { sound: v } ), __( 'Also sounds the alarm when the site flatlines.' ) ), + ); + + add( heading( __( 'Rhythm' ) ) ); + add( + rangeField( __( 'Resting heart rate (0 = flatline when quiet)' ), PULSE_LIMITS.restingBpm, 1, current.restingBpm, ( v ) => set( { restingBpm: Math.round( v ) } ) ), + switchField( + __( 'Diagnose site health' ), + current.diagnose, + ( v ) => set( { diagnose: v } ), + __( 'Failed requests, slow responses, JavaScript errors, Site Health issues and overdue cron change the rhythm: PVCs, fibrillation, tachycardia, flatline.' ), + ), + ); + + const catalog = sourceCatalog(); + const sourceSwitches = ( group: 'client' | 'server' ): HTMLElement[] => + catalog + .filter( ( s ) => s.group === group ) + .map( ( s ) => + switchField( s.label, ! current.muted.includes( s.id as SourceId ), ( on ) => { + const next = new Set( current.muted ); + if ( on ) { + next.delete( s.id as SourceId ); + } else { + next.add( s.id as SourceId ); + } + set( { muted: SOURCE_IDS.filter( ( id ) => next.has( id ) ) } ); + } ), + ); + + add( heading( __( 'Listening to — this browser' ) ) ); + add( ...sourceSwitches( 'client' ) ); + add( heading( __( 'Listening to — the whole site' ) ) ); + add( + note( __( 'Relayed over Heartbeat, so they arrive in batches and replay at their real spacing. Administrators only.' ) ), + ...sourceSwitches( 'server' ), + ); + + const reset = document.createElement( 'os-button' ); + reset.setAttribute( 'variant', 'ghost' ); + reset.style.alignSelf = 'flex-start'; + reset.style.marginTop = '8px'; + reset.textContent = __( 'Reset to defaults' ); + reset.addEventListener( 'click', () => { + set( { ...PULSE_DEFAULTS } ); + build(); + } ); + add( reset ); + }; + + build(); + return () => undefined; +} + diff --git a/src/plugins/pulse-monitor/health.ts b/src/plugins/pulse-monitor/health.ts new file mode 100644 index 00000000..1d7054bf --- /dev/null +++ b/src/plugins/pulse-monitor/health.ts @@ -0,0 +1,422 @@ +/** + * Pulse Monitor — site health diagnosis. + * + * Turns what the shell and the server observe into one of five + * conditions, each of which the rhythm engine draws as a recognisable + * ECG pattern: + * + * healthy regular sinus rhythm + * strained sinus with ectopic (PVC) beats and ST elevation + * sick atrial fibrillation — irregular, no P waves, quivering baseline + * critical ventricular tachycardia — fast, wide, alarm-coloured + * down asystole — a flatline + * + * Inputs over a sliding one-minute window: request outcomes and + * latency (shell + iframes), JavaScript errors, and the server's + * Heartbeat snapshot (Site Health criticals, 5xx share of site-wide + * traffic, overdue cron). Connectivity (offline, Heartbeat + * connection lost, session expired) is a hard override. + * + * Pure and clock-injected so it is unit-testable. + */ + +import { __, sprintf } from '../../i18n'; + +export type HealthState = 'healthy' | 'strained' | 'sick' | 'critical' | 'down'; + +/** The server half, as `includes/pulse-monitor.php` sends it. */ +export interface ServerHealth { + siteHealth: { good: number; recommended: number; critical: number } | null; + requests: number; + failures: number; + cronLate: number; +} + +export interface HealthReading { + state: HealthState; + /** 0 (dead) – 100 (perfect), smoothed. */ + score: number; + /** Share of requests in the window that succeeded, or null with too few to say. */ + successRate: number | null; + /** Median request latency in ms, or null. */ + latencyP50: number | null; + /** 95th-percentile request latency in ms, or null. */ + latencyP95: number | null; + /** Requests this shell saw settle over the last minute. */ + requestsPerMinute: number; + /** + * Front-end page views (visitors + bots) over the last minute, from + * the server feed — null when the feed is not reaching this user. + */ + visitsPerMinute: number | null; + /** Human-readable reasons the score is not 100, worst first. */ + reasons: string[]; + /** The same, itemised: what each cost the score, worst first. */ + penalties: Penalty[]; + /** + * The reason that justified the condition — what an abnormal beat + * prints. For VT it is always a failure, for fibrillation a failure + * or slowness; otherwise the worst reason. + */ + cause: string; +} + +interface RequestSample { + at: number; + /** 0 ok, 1 client error (4xx), 2 failure (5xx / network). */ + outcome: 0 | 1 | 2; +} + +interface LatencySample { + at: number; + ms: number; +} + +/** What a penalty is about — decides how far it can push the rhythm. */ +type PenaltyKind = 'config' | 'slow' | 'failure'; + +/** What a penalty is about — lets the Issues window explain it and link to the cure. */ +export type PenaltyTopic = + | 'failures' + | 'latency' + | 'js-errors' + | 'session' + | 'site-health-critical' + | 'site-health-recommended' + | 'server-errors' + | 'cron'; + +/** One item of the diagnosis. */ +export interface Penalty { + topic: PenaltyTopic; + kind: PenaltyKind; + /** Score points it costs (out of 100). */ + points: number; + reason: string; +} + +/** + * Failure points (failed requests, server errors, an expired session) + * a site needs before it may draw ventricular tachycardia — about 16 % + * of requests failing. Below it, however low the score, the worst + * rhythm is fibrillation: a slow site is not a failing one. + */ +const CRITICAL_FAILURE_POINTS = 40; + +/** + * Failure + slowness points needed for fibrillation. Below it the + * trouble is configuration (Site Health, overdue cron), which tops out + * at occasional PVCs. + */ +const SICK_TROUBLE_POINTS = 20; + +function stateForScore( score: number ): HealthState { + if ( score >= 80 ) { + return 'healthy'; + } + if ( score >= 55 ) { + return 'strained'; + } + return score >= 30 ? 'sick' : 'critical'; +} + +/** Fewer requests than this in the window and the error ratio is noise. */ +const MIN_SAMPLE = 4; + +/** + * Fewer durations than this and p95 is not reported. At 20 the 95th + * percentile is the second-slowest request, so one call that is long + * by design — an AI search, an upload, a streamed response — cannot + * set it on its own. + */ +const MIN_LATENCY_SAMPLE = 20; + +/** + * Longest duration one request contributes, ms. Past this a request + * is slow whatever it took, and an outlier this size would otherwise + * hold p50/p95 up for the whole window. + */ +const LATENCY_CAP_MS = 10000; + +/** Time constant of the score smoothing, ms. */ +const SMOOTHING_MS = 6000; + +/** Consecutive network-level failures that read as "the site is down". */ +const DOWN_AFTER_FAILURES = 5; + +export class HealthMonitor { + private requests: RequestSample[] = []; + private latencies: LatencySample[] = []; + private jsErrors: number[] = []; + private visits: number[] = []; + private server: ServerHealth | null = null; + private serverAt = -Infinity; + private offline = false; + private heartbeatLost = false; + private authLost = false; + private consecutiveFailures = 0; + private smoothed = 100; + private lastRead = -Infinity; + + private readonly windowMs: number; + + constructor( windowMs = 60000 ) { + this.windowMs = windowMs; + } + + /** + * One request settled. + * + * @param at Monitor time, ms. + * @param status HTTP status, or 0 for a network-level failure. + */ + recordRequest( at: number, status: number ): void { + let outcome: 0 | 1 | 2 = 0; + if ( status === 0 || status >= 500 ) { + outcome = 2; + } else if ( status >= 400 ) { + outcome = 1; + } + this.consecutiveFailures = status === 0 ? this.consecutiveFailures + 1 : 0; + this.requests.push( { at, outcome } ); + } + + /** + * One request's round-trip time. Kept apart from outcomes because + * they arrive from different places: the activity bus knows the + * status, the browser's resource timing knows the duration. + */ + recordLatency( at: number, ms: number ): void { + if ( Number.isFinite( ms ) && ms >= 0 ) { + this.latencies.push( { at, ms: Math.min( ms, LATENCY_CAP_MS ) } ); + } + } + + /** A front-end page view relayed by the server feed. */ + recordVisit( at: number ): void { + this.visits.push( at ); + } + + /** A JavaScript error surfaced in the shell or a window. */ + recordJsError( at: number ): void { + this.jsErrors.push( at ); + } + + setServer( at: number, server: ServerHealth ): void { + this.server = server; + this.serverAt = at; + } + + setOffline( offline: boolean ): void { + this.offline = offline; + } + + setHeartbeatLost( lost: boolean ): void { + this.heartbeatLost = lost; + } + + setAuthLost( lost: boolean ): void { + this.authLost = lost; + } + + private prune( now: number ): void { + const cutoff = now - this.windowMs; + while ( this.requests.length && this.requests[ 0 ].at < cutoff ) { + this.requests.shift(); + } + while ( this.latencies.length && this.latencies[ 0 ].at < cutoff ) { + this.latencies.shift(); + } + while ( this.jsErrors.length && this.jsErrors[ 0 ] < cutoff ) { + this.jsErrors.shift(); + } + while ( this.visits.length && this.visits[ 0 ] < cutoff ) { + this.visits.shift(); + } + // A server snapshot older than three windows is stale (Heartbeat + // stopped answering, or the user lost the capability). + if ( now - this.serverAt > this.windowMs * 3 ) { + this.server = null; + } + } + + /** Compute the current reading. Advances the smoothing clock. */ + read( now: number ): HealthReading { + this.prune( now ); + // Each penalty has a kind, because kinds cap how bad the rhythm can + // get (see CRITICAL_FAILURE_POINTS): a slow or misconfigured site + // is not a failing one, and must never draw ventricular tachycardia. + const penalties: Penalty[] = []; + + const total = this.requests.length; + let failed = 0; + let clientErrors = 0; + for ( const sample of this.requests ) { + if ( sample.outcome === 2 ) { + failed++; + } else if ( sample.outcome === 1 ) { + clientErrors++; + } + } + const durations = this.latencies.map( ( l ) => l.ms ); + const successRate = total >= MIN_SAMPLE ? ( total - failed - clientErrors ) / total : null; + if ( total >= MIN_SAMPLE && ( failed || clientErrors ) ) { + const ratio = ( failed + clientErrors * 0.25 ) / total; + penalties.push( { + topic: 'failures', + kind: 'failure', + points: Math.min( 80, ratio * 250 ), + /* translators: %d: percentage of failed requests. */ + reason: sprintf( __( '%d%% of requests failing' ), Math.round( ( ( failed + clientErrors ) / total ) * 100 ) ), + } ); + } + + durations.sort( ( a, b ) => a - b ); + // Nearest-rank percentile. + const pct = ( p: number ): number | null => + durations.length + ? durations[ Math.min( durations.length - 1, Math.max( 0, Math.ceil( p * durations.length ) - 1 ) ) ] + : null; + const p50 = pct( 0.5 ); + const p95 = durations.length >= MIN_LATENCY_SAMPLE ? pct( 0.95 ) : null; + if ( p95 !== null && p95 > 800 ) { + penalties.push( { + topic: 'latency', + kind: 'slow', + points: Math.min( 25, ( p95 - 800 ) / 80 ), + /* translators: %s: latency in seconds, e.g. "2.4". */ + reason: sprintf( __( 'slow responses (p95 %s s)' ), ( p95 / 1000 ).toFixed( 1 ) ), + } ); + } + + const errorsPerMinute = ( this.jsErrors.length * 60000 ) / this.windowMs; + if ( errorsPerMinute > 0 ) { + penalties.push( { + topic: 'js-errors', + kind: 'slow', + points: Math.min( 20, errorsPerMinute * 5 ), + /* translators: %d: number of JavaScript errors in the last minute. */ + reason: sprintf( __( '%d JavaScript errors/min' ), Math.round( errorsPerMinute ) ), + } ); + } + + if ( this.authLost ) { + penalties.push( { topic: 'session', kind: 'failure', points: 30, reason: __( 'session expired' ) } ); + } + + const server = this.server; + if ( server ) { + const critical = server.siteHealth?.critical ?? 0; + const recommended = server.siteHealth?.recommended ?? 0; + if ( critical > 0 ) { + penalties.push( { + topic: 'site-health-critical', + kind: 'config', + points: Math.min( 30, critical * 10 ), + /* translators: %d: number of critical Site Health issues. */ + reason: sprintf( __( '%d critical Site Health issues' ), critical ), + } ); + } + if ( recommended > 0 ) { + penalties.push( { + topic: 'site-health-recommended', + kind: 'config', + points: Math.min( 5, recommended ), + /* translators: %d: number of recommended Site Health improvements. */ + reason: sprintf( __( '%d Site Health recommendations' ), recommended ), + } ); + } + if ( server.requests >= MIN_SAMPLE && server.failures > 0 ) { + const ratio = server.failures / server.requests; + penalties.push( { + topic: 'server-errors', + kind: 'failure', + points: Math.min( 40, ratio * 200 ), + /* translators: %d: percentage of site-wide requests ending in a server error. */ + reason: sprintf( __( '%d%% server errors site-wide' ), Math.round( ratio * 100 ) ), + } ); + } + if ( server.cronLate > 600 ) { + penalties.push( { + topic: 'cron', + kind: 'config', + points: Math.min( 15, ( server.cronLate / 600 ) * 4 ), + /* translators: %d: minutes the scheduled tasks are overdue. */ + reason: sprintf( __( 'cron %d min overdue' ), Math.round( server.cronLate / 60 ) ), + } ); + } + } + + penalties.sort( ( a, b ) => b.points - a.points ); + const raw = Math.max( 0, 100 - penalties.reduce( ( sum, p ) => sum + p.points, 0 ) ); + + // Smooth, so one failed request is a skipped beat and not a + // rhythm change. Worsening moves faster than recovering — a + // monitor should be quick to alarm and slow to stand down. + const dt = Number.isFinite( this.lastRead ) ? Math.max( 0, now - this.lastRead ) : SMOOTHING_MS * 10; + this.lastRead = now; + const tau = raw < this.smoothed ? SMOOTHING_MS / 2 : SMOOTHING_MS; + this.smoothed += ( raw - this.smoothed ) * ( 1 - Math.exp( -dt / tau ) ); + const score = Math.round( this.smoothed ); + + const reasons = penalties.map( ( p ) => p.reason ); + const pointsOf = ( kinds: PenaltyKind[] ): number => + penalties.filter( ( p ) => kinds.includes( p.kind ) ).reduce( ( sum, p ) => sum + p.points, 0 ); + const topReasonOf = ( kinds: PenaltyKind[] ): string => + penalties.find( ( p ) => kinds.includes( p.kind ) )?.reason ?? reasons[ 0 ] ?? ''; + let state: HealthState; + let cause = reasons[ 0 ] ?? ''; + if ( this.offline || this.heartbeatLost || this.consecutiveFailures >= DOWN_AFTER_FAILURES ) { + state = 'down'; + reasons.unshift( this.offline ? __( 'offline' ) : __( 'server not answering' ) ); + cause = reasons[ 0 ]; + } else { + // The score says how bad; the kind of trouble caps how bad it + // may look. Only substantial failure reaches ventricular + // tachycardia, only failure or slowness reaches fibrillation. + state = stateForScore( score ); + if ( state === 'critical' && pointsOf( [ 'failure' ] ) < CRITICAL_FAILURE_POINTS ) { + state = 'sick'; + } + if ( state === 'sick' && pointsOf( [ 'failure', 'slow' ] ) < SICK_TROUBLE_POINTS ) { + state = 'strained'; + } + // The reason printed on the rhythm's beats is the one that + // justified it: a failure for VT, failure or slowness for AF. + if ( state === 'critical' ) { + cause = topReasonOf( [ 'failure' ] ); + } else if ( state === 'sick' ) { + cause = topReasonOf( [ 'failure', 'slow' ] ); + } + } + + return { + state, + score: state === 'down' ? 0 : score, + successRate, + latencyP50: p50, + latencyP95: p95, + requestsPerMinute: Math.round( ( total * 60000 ) / this.windowMs ), + visitsPerMinute: this.server ? Math.round( ( this.visits.length * 60000 ) / this.windowMs ) : null, + reasons, + penalties, + cause, + }; + } +} + +/** Readout label for a condition — what a monitor would print. */ +export function rhythmLabel( state: HealthState ): string { + switch ( state ) { + case 'strained': + return __( 'Sinus rhythm with PVCs' ); + case 'sick': + return __( 'Atrial fibrillation' ); + case 'critical': + return __( 'Ventricular tachycardia' ); + case 'down': + return __( 'Asystole' ); + default: + return __( 'Normal sinus rhythm' ); + } +} diff --git a/src/plugins/pulse-monitor/hub.ts b/src/plugins/pulse-monitor/hub.ts new file mode 100644 index 00000000..ab71d262 --- /dev/null +++ b/src/plugins/pulse-monitor/hub.ts @@ -0,0 +1,101 @@ +/** + * Pulse Monitor — the shared signal hub. + * + * The wallpaper and the widget can both be on the desk at once. They + * must not each wire the taps: two `PerformanceObserver`s, two jQuery + * Heartbeat bindings and — worse — two contributors to the one + * `openstation_pulse` Heartbeat field (last writer wins, so the first + * monitor's server feed would silently stop). So the taps, the server + * feed and the {@link HealthMonitor} live here, once per page, and + * every mounted monitor subscribes. + * + * Reference-counted: the first {@link acquirePulseHub} wires the taps, + * the last release unwires them. Module state is safe because the + * wallpaper and the widget ship in the same bundle. + */ + +import { HealthMonitor } from './health'; +import { IncidentLog } from './incidents'; +import type { BeatRequest } from './rhythm'; +import { wireSignals } from './signals'; + +/** Receives every beat; `delayMs` > 0 means "draw this later". */ +export type BeatListener = ( beat: BeatRequest, delayMs: number ) => void; + +export interface PulseHub { + /** Shared diagnosis. Read it with `performance.now()`. */ + health: HealthMonitor; + /** Every failure this page saw, with its URL and status — the Issues window's list. */ + incidents: IncidentLog; + /** Listen to every beat. Returns the unsubscribe. */ + subscribe( listener: BeatListener ): () => void; +} + +let refs = 0; +let health: HealthMonitor | null = null; +let incidents: IncidentLog | null = null; +let unwire: ( () => void ) | null = null; +const listeners = new Set< BeatListener >(); + +const hub: PulseHub = { + get health(): HealthMonitor { + if ( ! health ) { + health = new HealthMonitor(); + } + return health; + }, + get incidents(): IncidentLog { + if ( ! incidents ) { + incidents = new IncidentLog(); + } + return incidents; + }, + subscribe( listener ) { + listeners.add( listener ); + return () => { + listeners.delete( listener ); + }; + }, +}; + +/** + * Take a reference on the hub, wiring the taps on the first one. + * Call the returned `release` exactly once. + */ +export function acquirePulseHub(): { hub: PulseHub; release: () => void } { + refs++; + if ( refs === 1 ) { + unwire = wireSignals( { + health: hub.health, + incidents: hub.incidents, + emit: ( beat, delayMs ) => { + for ( const listener of listeners ) { + try { + listener( beat, delayMs ); + } catch { + /* one broken monitor must not starve the others */ + } + } + }, + } ); + } + let released = false; + return { + hub, + release: () => { + if ( released ) { + return; + } + released = true; + refs--; + if ( refs === 0 ) { + unwire?.(); + unwire = null; + // A fresh diagnosis next time: stale observations from a + // previous session on the desk would only mislead. + health = null; + incidents = null; + } + }, + }; +} diff --git a/src/plugins/pulse-monitor/incidents.ts b/src/plugins/pulse-monitor/incidents.ts new file mode 100644 index 00000000..b420318e --- /dev/null +++ b/src/plugins/pulse-monitor/incidents.ts @@ -0,0 +1,81 @@ +/** + * Pulse Monitor — the incident log. + * + * Every amber beat is a summary: "request · 404". The Issues window + * needs the rest of the story — which URL, which window, when — so the + * taps that draw a failed beat also file an incident here. One log per + * page, on the shared hub, so the wallpaper, the widget and the Issues + * window all read the same list. + * + * Everything in it was observed by this browser, about this browser's + * own traffic, and never leaves the page. Server-side events arrive + * through the Heartbeat feed without URLs or IPs (see + * `includes/pulse-monitor.php`), so their incidents carry a kind and a + * status only. + */ + +/** One thing that went wrong. */ +export interface Incident { + /** Unix ms. */ + at: number; + /** Signal source id — `requests`, `errors`, `heartbeat`, `visitors`… */ + source: string; + /** Short reason, as the spike label prints it ("404", "TypeError: …"). */ + detail: string; + /** HTTP status, when there was one. */ + status?: number; + method?: string; + url?: string; + /** Owning window, for a report from inside one. */ + windowId?: string; + /** Where a script error was thrown (`file:line`). */ + where?: string; + /** True for an event the server feed relayed (another visitor, a bot…). */ + server?: boolean; +} + +/** How many incidents are kept; older ones fall off. */ +export const INCIDENT_MAX = 200; + +type Listener = () => void; + +export class IncidentLog { + private items: Incident[] = []; + private readonly listeners = new Set< Listener >(); + + add( incident: Omit< Incident, 'at' > & { at?: number } ): void { + this.items.push( { ...incident, at: incident.at ?? Date.now() } ); + if ( this.items.length > INCIDENT_MAX ) { + this.items.splice( 0, this.items.length - INCIDENT_MAX ); + } + this.notify(); + } + + /** Newest first. */ + list(): Incident[] { + return this.items.slice().reverse(); + } + + clear(): void { + this.items = []; + this.notify(); + } + + /** Called after every change; returns the unsubscribe. */ + subscribe( listener: () => void ): () => void { + this.listeners.add( listener ); + return () => { + this.listeners.delete( listener ); + }; + } + + private notify(): void { + for ( const listener of this.listeners ) { + try { + listener(); + } catch { + /* a broken view must not stop the others */ + } + } + } +} diff --git a/src/plugins/pulse-monitor/index.ts b/src/plugins/pulse-monitor/index.ts new file mode 100644 index 00000000..f681746d --- /dev/null +++ b/src/plugins/pulse-monitor/index.ts @@ -0,0 +1,71 @@ +/** + * Pulse Monitor — bundle entry. + * + * A PixiJS ECG wired to the site: every HTTP request the shell makes or + * sees, every signal the desk emits (windows, clicks, Heartbeat, + * messages, errors) and every site-wide event the server relays over + * Heartbeat (visitors, bots, comments, logins, cron, API) is a beat, + * labelled with its cause. The rhythm is a diagnosis: a healthy site + * keeps a sinus rhythm; failures, slow responses and outages turn it + * into PVCs, atrial fibrillation, ventricular tachycardia or a + * flatline. Failed (amber) beats say why. + * + * One bundle, two faces, so the engine loads once whichever is on the + * desk — or both: + * + * wallpaper.ts the canvas wallpaper `wp-pulse` + * widget.ts the widget `desktop-mode/pulse` + * + * Shared by both: + * + * monitor.ts one mounted trace: rhythm + scene + numbers + labels + * hub.ts the page's single set of signal taps + diagnosis + * signals.ts every tap, and the Heartbeat server feed + * health.ts the diagnosis + * rhythm.ts beat scheduling + waveform synthesis + * scene.ts the Pixi trace and spike labels + * readout.ts the numbers and the beeper + * incidents.ts every failure with its URL, status and window + * issues.ts the Issues window: the diagnosis, itemised, and the incidents + * config.ts the settings form (each face edits its own settings) + * sources.ts the signal catalog + * settings.ts settings sanitizing + * + * Server side: `includes/pulse-monitor.php` (feed, script handle, + * widget registration) and `includes/wallpapers.php` (wallpaper). + */ + +import { __ } from '../../i18n'; +import type { WallpaperDef } from '../../wallpapers/types'; +import type { WidgetContext, WidgetTeardown } from '../../widgets/types'; +import { openIssues } from './issues'; +import { WALLPAPER_ID, wallpaperDef } from './wallpaper'; +import { WIDGET_ID, mountWidget } from './widget'; + +type WidgetMount = ( container: HTMLElement, ctx: WidgetContext ) => WidgetTeardown | Promise< WidgetTeardown >; + +const w = window as unknown as { + openStationWallpapers?: Record< string, WallpaperDef >; + openStationWidgets?: Record< string, WidgetMount >; +}; + +w.openStationWallpapers = w.openStationWallpapers || {}; +w.openStationWallpapers[ WALLPAPER_ID ] = wallpaperDef; + +w.openStationWidgets = w.openStationWidgets || {}; +w.openStationWidgets[ WIDGET_ID ] = mountWidget; + +// "Pulse Monitor: issues" in the command palette — the way in when the +// trace is the full-screen wallpaper, under the desk and not clickable. +( window.wp as unknown as { + os?: { registerCommand?: ( cmd: Record< string, unknown > ) => void }; +} | undefined )?.os?.registerCommand?.( { + slug: 'pulse-issues', + label: __( 'Pulse Monitor: issues' ), + description: __( 'Why the site’s health score is what it is, and every failed request and error with its URL.' ), + icon: 'dashicons-heart', + run: ( _args: string, ctx: { close?: () => void } ) => { + openIssues(); + ctx?.close?.(); + }, +} ); diff --git a/src/plugins/pulse-monitor/issues.ts b/src/plugins/pulse-monitor/issues.ts new file mode 100644 index 00000000..1e7b95be --- /dev/null +++ b/src/plugins/pulse-monitor/issues.ts @@ -0,0 +1,276 @@ +/** + * Pulse Monitor — the Issues window. + * + * The trace says something is wrong; this says what. A native window, + * opened from the readout (widget card, reserved band), from the + * settings form, or from the command palette, with: + * + * - the diagnosis: rhythm, score, and each item holding the score + * down with what it costs, what it means and, where there is one, + * the screen that fixes it (Site Health); + * - every failure this page saw — newest first, with the URL, method, + * status, owning window and, for script errors, file and line. + * + * It holds a reference on the shared hub while open, so it keeps + * listening even with no monitor on the desk. + */ + +import { __, sprintf } from '../../i18n'; +import '../../ui/components/os-button/os-button'; +import { rhythmLabel, type HealthReading, type Penalty, type PenaltyTopic } from './health'; +import { acquirePulseHub } from './hub'; +import type { Incident } from './incidents'; +import { spikeLabel } from './sources'; + +export const ISSUES_WINDOW_ID = 'desktop-mode/pulse-issues'; + +const STYLE_ID = 'os-pulse-issues-style'; + +function ensureStyle(): void { + if ( document.getElementById( STYLE_ID ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = STYLE_ID; + style.textContent = ` +.os-pulse-issues { + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 16px 20px 24px; + color: var( --os-ui-fg, currentColor ); + font-size: 13px; + line-height: 1.45; +} +.os-pulse-issues h3 { margin: 18px 0 8px; font-size: 13px; font-weight: 600; color: var( --os-ui-fg, currentColor ); } +.os-pulse-issues__muted { color: var( --os-ui-fg-muted, currentColor ); } +.os-pulse-issues__status { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } +.os-pulse-issues__score { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; } +.os-pulse-issues__dot { inline-size: 10px; block-size: 10px; border-radius: 50%; display: inline-block; } +.os-pulse-issues__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } +.os-pulse-issues__item { + padding: 8px 10px; + border-radius: 8px; + background: color-mix( in srgb, var( --os-ui-fg, #888 ) 5%, transparent ); +} +.os-pulse-issues__row { display: flex; gap: 10px; align-items: baseline; } +.os-pulse-issues__points { font-variant-numeric: tabular-nums; font-weight: 700; min-inline-size: 3ch; color: #ffb020; } +.os-pulse-issues__when { font-variant-numeric: tabular-nums; color: var( --os-ui-fg-muted, currentColor ); min-inline-size: 8ch; } +.os-pulse-issues__what { font-weight: 600; } +.os-pulse-issues__meta { + margin-block-start: 2px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + color: var( --os-ui-fg-muted, currentColor ); + overflow-wrap: anywhere; +} +.os-pulse-issues__bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +`; + document.head.appendChild( style ); +} + +/** What a penalty means, in a sentence. */ +function explain( topic: PenaltyTopic ): string { + switch ( topic ) { + case 'failures': + return __( 'Requests from this browser that got a 4xx or 5xx answer, or none at all, in the last minute. They are listed under Recent problems.' ); + case 'latency': + return __( 'The slowest 5 % of the last minute’s requests took longer than 0.8 s. Calls that are long by design (AI, uploads) count for at most 10 s each.' ); + case 'js-errors': + return __( 'Script errors in the shell or inside a window. Each is listed below with its file and line.' ); + case 'session': + return __( 'Your login expired. Sign in again to keep working.' ); + case 'site-health-critical': + case 'site-health-recommended': + return __( 'From the last Site Health check. Open Site Health for the details and how to fix each one.' ); + case 'server-errors': + return __( 'Other people’s requests — visitors, bots, apps — that ended in a server error. URLs are not relayed, for privacy; the server’s error log has them.' ); + case 'cron': + return __( 'Scheduled tasks are running late. WP-Cron only runs when the site gets traffic, and fails when the site cannot call itself. Site Health’s scheduled events and loopback tests say which.' ); + } + return ''; +} + +function adminUrl( path: string ): string { + const ajax = ( window as unknown as { ajaxurl?: string } ).ajaxurl; + return ajax ? ajax.replace( /admin-ajax\.php.*$/, path ) : path; +} + +/** Open an admin screen as a window, the way the shell's own notices do. */ +function openAdminScreen( path: string, title: string, icon: string ): void { + const manager = ( window.wp as unknown as { + os?: { windowManager?: { open?: ( cfg: Record< string, unknown > ) => unknown } }; + } | undefined )?.os?.windowManager; + const id = path.replace( /[^a-z0-9]+/gi, '-' ).replace( /-+$/, '' ); + void manager?.open?.( { id, baseId: id, url: adminUrl( path ), title, icon } ); +} + +function fixFor( topic: PenaltyTopic ): HTMLElement | null { + if ( topic !== 'site-health-critical' && topic !== 'site-health-recommended' && topic !== 'cron' ) { + return null; + } + const button = document.createElement( 'os-button' ); + button.setAttribute( 'variant', 'secondary' ); + button.textContent = __( 'Open Site Health' ); + button.addEventListener( 'click', () => openAdminScreen( 'site-health.php', __( 'Site Health' ), 'dashicons-heart' ) ); + return button; +} + +function clock( at: number ): string { + return new Date( at ).toLocaleTimeString( undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' } ); +} + +function stateColour( reading: HealthReading ): string { + switch ( reading.state ) { + case 'healthy': + return '#3ecf8e'; + case 'strained': + return '#ffd34d'; + case 'sick': + return '#ffb020'; + default: + return '#ff4d4d'; + } +} + +function el< K extends keyof HTMLElementTagNameMap >( tag: K, className = '', text = '' ): HTMLElementTagNameMap[ K ] { + const node = document.createElement( tag ); + if ( className ) { + node.className = className; + } + if ( text ) { + node.textContent = text; + } + return node; +} + +function penaltyItem( p: Penalty ): HTMLElement { + const li = el( 'li', 'os-pulse-issues__item' ); + const row = el( 'div', 'os-pulse-issues__row' ); + row.append( el( 'span', 'os-pulse-issues__points', `−${ Math.round( p.points ) }` ), el( 'span', 'os-pulse-issues__what', p.reason ) ); + li.append( row, el( 'div', 'os-pulse-issues__muted', explain( p.topic ) ) ); + const fix = fixFor( p.topic ); + if ( fix ) { + fix.style.marginBlockStart = '6px'; + li.append( fix ); + } + return li; +} + +function incidentItem( i: Incident ): HTMLElement { + const li = el( 'li', 'os-pulse-issues__item' ); + const row = el( 'div', 'os-pulse-issues__row' ); + row.append( + el( 'span', 'os-pulse-issues__when', clock( i.at ) ), + el( 'span', 'os-pulse-issues__what', spikeLabel( i.source ) ), + el( 'span', '', i.detail ), + ); + li.append( row ); + const meta: string[] = []; + if ( i.url ) { + meta.push( `${ i.method ? `${ i.method.toUpperCase() } ` : '' }${ i.url }` ); + } + if ( i.where ) { + meta.push( i.where ); + } + if ( i.windowId ) { + /* translators: %s: id of the window the problem happened in. */ + meta.push( sprintf( __( 'window: %s' ), i.windowId ) ); + } + if ( i.server ) { + meta.push( __( 'site-wide, from the server feed (no URL is relayed)' ) ); + } + if ( meta.length ) { + li.append( el( 'div', 'os-pulse-issues__meta', meta.join( ' · ' ) ) ); + } + return li; +} + +/** Render the Issues view into `body`. Returns the teardown. */ +export function renderIssues( body: HTMLElement ): () => void { + ensureStyle(); + const { hub, release } = acquirePulseHub(); + const root = el( 'div', 'os-pulse-issues' ); + body.appendChild( root ); + + const status = el( 'div', 'os-pulse-issues__status' ); + const whyList = el( 'ul', 'os-pulse-issues__list' ); + const whyEmpty = el( 'p', 'os-pulse-issues__muted', __( 'Nothing is holding the score down.' ) ); + const recentBar = el( 'div', 'os-pulse-issues__bar' ); + const recentTitle = el( 'h3' ); + const clear = document.createElement( 'os-button' ); + clear.setAttribute( 'variant', 'ghost' ); + clear.textContent = __( 'Clear' ); + clear.addEventListener( 'click', () => hub.incidents.clear() ); + recentBar.append( recentTitle, clear ); + const recentList = el( 'ul', 'os-pulse-issues__list' ); + const recentEmpty = el( 'p', 'os-pulse-issues__muted', __( 'No problems since this page loaded.' ) ); + + root.append( + status, + el( 'h3', '', __( 'Why' ) ), + whyList, + whyEmpty, + recentBar, + recentList, + recentEmpty, + ); + + const paintStatus = (): void => { + const reading = hub.health.read( performance.now() ); + status.replaceChildren(); + const dot = el( 'span', 'os-pulse-issues__dot' ); + dot.style.background = stateColour( reading ); + status.append( + dot, + el( 'span', 'os-pulse-issues__score', String( reading.score ) ), + el( 'span', 'os-pulse-issues__muted', __( '/ 100' ) ), + el( 'span', 'os-pulse-issues__what', rhythmLabel( reading.state ) ), + ); + whyList.replaceChildren( ...reading.penalties.map( penaltyItem ) ); + whyEmpty.hidden = reading.penalties.length > 0; + }; + let queued = 0; + const paintRecent = (): void => { + queued = 0; + const items = hub.incidents.list(); + /* translators: %d: number of problems listed. */ + recentTitle.textContent = items.length ? sprintf( __( 'Recent problems (%d)' ), items.length ) : __( 'Recent problems' ); + recentList.replaceChildren( ...items.map( incidentItem ) ); + recentEmpty.hidden = items.length > 0; + clear.hidden = items.length === 0; + }; + paintStatus(); + paintRecent(); + const timer = window.setInterval( paintStatus, 1000 ); + const off = hub.incidents.subscribe( () => { + if ( ! queued ) { + queued = window.requestAnimationFrame( paintRecent ); + } + } ); + + return (): void => { + window.clearInterval( timer ); + window.cancelAnimationFrame( queued ); + off(); + root.remove(); + release(); + }; +} + +/** Open (or focus) the Issues window. */ +export function openIssues(): void { + const os = ( window.wp as unknown as { + os?: { registerWindow?: ( def: Record< string, unknown > ) => unknown }; + } | undefined )?.os; + void os?.registerWindow?.( { + id: ISSUES_WINDOW_ID, + title: __( 'Pulse Monitor — Issues' ), + icon: 'dashicons-heart', + width: 560, + height: 620, + minWidth: 360, + minHeight: 300, + render: ( body: HTMLElement ) => renderIssues( body ), + } ); +} diff --git a/src/plugins/pulse-monitor/monitor.ts b/src/plugins/pulse-monitor/monitor.ts new file mode 100644 index 00000000..0a11491e --- /dev/null +++ b/src/plugins/pulse-monitor/monitor.ts @@ -0,0 +1,192 @@ +/** + * Pulse Monitor — one mounted monitor. + * + * The composition the wallpaper and the widget both mount: its own + * {@link Rhythm} and Pixi scene, subscribed to the page's shared + * {@link PulseHub}, plus the numbers readout, the spike labels and the + * beeper. The wallpaper and the widget differ only in the scene + * `variant` and where the readout sits. + * + * Muting is per monitor: the hub hands every beat to every monitor and + * each drops the sources its settings switched off. The diagnosis is + * shared — muting quiets a trace, it never blinds the health reading. + */ + +import { doAction } from '../../hooks'; +import type { HealthReading } from './health'; +import { acquirePulseHub } from './hub'; +import { openIssues } from './issues'; +import type { PixiNamespace } from './pixi-types'; +import { Beeper, createReadout } from './readout'; +import { Rhythm, type BeatStarted } from './rhythm'; +import { mountPulseScene, type PulseSceneOptions } from './scene'; +import type { PulseSettings } from './settings'; +import { spikeLabel } from './sources'; + +/** Action fired for every beat a monitor draws. */ +export const BEAT_HOOK = 'os.pulse.beat'; + +/** How often the diagnosis and the readout refresh, ms. */ +const REFRESH_MS = 250; + +export interface MonitorOptions { + container: HTMLElement; + pixi: PixiNamespace; + settings: PulseSettings; + prefersReducedMotion: boolean; + variant: NonNullable< PulseSceneOptions[ 'variant' ] >; +} + +export interface Monitor { + applySettings( settings: PulseSettings ): void; + setAnimating( animating: boolean ): void; + destroy(): void; +} + +/** + * The text printed on a spike: what caused it and, for a failed + * (amber) beat, why. A beat the diagnosis made abnormal names the + * rhythm and its leading cause. + * + * request · 404 + * heartbeat · timeout + * PVC: slow responses (p95 3.1 s) (a resting beat) + * request · VT: 42% of requests failing + */ +export function spikeText( beat: Pick< BeatStarted, 'source' | 'shape' | 'error' | 'detail' | 'ectopic' > ): string { + const cause = beat.source === 'rest' ? '' : spikeLabel( beat.source ); + let why = beat.error ? beat.detail : ''; + if ( beat.ectopic ) { + const name = beat.shape === 'vt' ? 'VT' : 'PVC'; + why = why ? `${ name }: ${ why }` : name; + } + return [ cause, why ].filter( Boolean ).join( ' · ' ); +} + +export async function mountMonitor( opts: MonitorOptions ): Promise< Monitor > { + let settings = opts.settings; + let muted = new Set< string >( settings.muted ); + const { hub, release } = acquirePulseHub(); + const beeper = new Beeper(); + const soundOn = ( s: PulseSettings ): boolean => s.sound && opts.variant !== 'preview'; + beeper.setEnabled( soundOn( settings ) ); + // Hidden tab or suspended wallpaper: the trace is frozen, so beats + // are dropped rather than queued (the rhythm only drains its queues + // while the scene ticks — a long pause would replay as a burst). + // Reduced motion draws one still frame and never ticks at all. + let suspended = false; + + // Assigned once the scene mounts; beats only start inside the + // scene's own step, so it is always set by the time one fires. + let labelBeat: ( ( text: string, error: boolean, durationMs: number ) => void ) | null = null; + const rhythm = new Rhythm( { + restingBpm: settings.restingBpm, + onBeat: ( beat ) => { + if ( soundOn( settings ) && beat.major ) { + beeper.beep( beat.error ? 520 : 880, 70 ); + } + // Name every spike a person caused, and every failed beat — + // not the idle pacemaker's normal beats, and not the tiny + // blips (clicks, assets) that would bury the trace in words. + const named = beat.source !== 'rest' || beat.error; + if ( labelBeat && named && ( beat.shape !== 'blip' || beat.error ) ) { + labelBeat( spikeText( beat ), beat.error, beat.duration ); + } + /** + * Fires for every beat a monitor draws. Payload: + * `{ source, shape, error, major, duration, detail, surface }` + * — `source` is the signal id (`requests`, `visitors`, …) or + * `rest` for the idle pacemaker; `surface` is `wallpaper` or + * `widget`. + */ + // The band is the wallpaper face in its band layout. + doAction( BEAT_HOOK, { ...beat, surface: opts.variant === 'band' ? 'wallpaper' : opts.variant } ); + }, + } ); + + let scene; + try { + scene = await mountPulseScene( { + container: opts.container, + pixi: opts.pixi, + settings, + prefersReducedMotion: opts.prefersReducedMotion, + rhythm, + variant: opts.variant, + } ); + } catch ( err ) { + release(); + beeper.destroy(); + throw err; + } + labelBeat = scene.labelBeat; + const mounted = scene; + + const unsubscribe = hub.subscribe( ( beat, delayMs ) => { + if ( suspended || opts.prefersReducedMotion || muted.has( beat.source ) ) { + return; + } + const next = beat.error && muted.has( 'errors' ) ? { ...beat, error: false, detail: undefined } : beat; + rhythm.push( next, delayMs > 0 ? mounted.now() + delayMs : undefined ); + } ); + + // The widget card and the reserved band can be clicked; the + // full-screen wallpaper lies under the desk and cannot. + const readout = createReadout( + opts.container, + opts.variant === 'widget' || opts.variant === 'band' ? opts.variant : 'wallpaper', + opts.variant === 'widget' || opts.variant === 'band' ? openIssues : undefined, + ); + readout.setVisible( settings.readout ); + + let lastAlarm = 0; + const refresh = (): void => { + const real = hub.health.read( performance.now() ); + const reading: HealthReading = settings.diagnose ? real : { ...real, state: 'healthy', reasons: [], cause: '' }; + rhythm.setState( reading.state, reading.cause ); + if ( settings.readout ) { + readout.update( { reading, lineColor: settings.lineColor, errorColor: settings.errorColor } ); + } + // The flatline alarm: one long tone every two seconds. + if ( soundOn( settings ) && reading.state === 'down' && performance.now() - lastAlarm > 2000 ) { + lastAlarm = performance.now(); + beeper.beep( 440, 900, 0.04 ); + } + }; + refresh(); + // Paused with the trace: a hidden or suspended monitor neither + // redraws its numbers nor sounds the flatline alarm. + let timer = window.setInterval( refresh, REFRESH_MS ); + + return { + applySettings( next: PulseSettings ): void { + settings = next; + muted = new Set( settings.muted ); + rhythm.setRestingBpm( settings.restingBpm ); + beeper.setEnabled( soundOn( settings ) ); + mounted.applySettings( settings ); + readout.setVisible( settings.readout ); + refresh(); + }, + setAnimating( animating: boolean ): void { + mounted.setAnimating( animating ); + if ( suspended === ! animating ) { + return; + } + suspended = ! animating; + window.clearInterval( timer ); + if ( animating ) { + refresh(); + timer = window.setInterval( refresh, REFRESH_MS ); + } + }, + destroy(): void { + window.clearInterval( timer ); + unsubscribe(); + readout.destroy(); + beeper.destroy(); + mounted.destroy(); + release(); + }, + }; +} diff --git a/src/plugins/pulse-monitor/pixi-types.ts b/src/plugins/pulse-monitor/pixi-types.ts new file mode 100644 index 00000000..c35eb3b7 --- /dev/null +++ b/src/plugins/pulse-monitor/pixi-types.ts @@ -0,0 +1,81 @@ +/** + * Pulse Monitor — minimal Pixi type surface. + * + * PixiJS is loaded as a vendor script (`window.PIXI`) via the + * wallpaper def's `needs: ['pixijs']`, NOT imported. We declare the + * narrow set of Pixi v8 types this bundle uses — `Graphics` paths for + * the trace and an optional `BlurFilter` for the phosphor glow — + * mirroring the other canvas wallpapers' `pixi-types.ts`. + */ + +export interface PixiStrokeStyle { + color: number; + width: number; + alpha?: number; + cap?: 'butt' | 'round' | 'square'; + join?: 'bevel' | 'miter' | 'round'; +} + +export interface PixiContainer { + alpha: number; + visible: boolean; + filters: unknown[] | null; + addChild( ...children: unknown[] ): void; + destroy( opts?: unknown ): void; +} + +export interface PixiGraphics extends PixiContainer { + clear(): PixiGraphics; + moveTo( x: number, y: number ): PixiGraphics; + lineTo( x: number, y: number ): PixiGraphics; + circle( x: number, y: number, r: number ): PixiGraphics; + stroke( style: PixiStrokeStyle ): PixiGraphics; + fill( style: { color: number; alpha?: number } ): PixiGraphics; +} + +export interface PixiTicker { + deltaMS: number; + add( cb: ( ticker: PixiTicker ) => void ): void; + remove( cb: ( ticker: PixiTicker ) => void ): void; + start(): void; + stop(): void; +} + +export interface PixiApp { + canvas: HTMLCanvasElement; + stage: PixiContainer; + screen: { width: number; height: number }; + ticker: PixiTicker; + init( opts: { + resizeTo?: HTMLElement; + backgroundAlpha?: number; + antialias?: boolean; + autoDensity?: boolean; + resolution?: number; + } ): Promise< void >; + render(): void; + /** Re-measure `resizeTo` now (the resize plugin only listens to the browser window). */ + resize?(): void; + /** + * Pass an options object, never a literal `true` — `true` releases + * Pixi's page-global pools out from under every other live + * Application (the active wallpaper vs. an OS Settings preview). + */ + destroy( + rendererOpts?: { removeView?: boolean }, + opts?: { children?: boolean; texture?: boolean; textureSource?: boolean }, + ): void; +} + +export interface PixiNamespace { + Application: new () => PixiApp; + Container: new () => PixiContainer; + Graphics: new () => PixiGraphics; + BlurFilter?: new ( opts?: { strength?: number; quality?: number } ) => unknown; +} + +/** Read the vendor-loaded Pixi namespace off `window`, or null. */ +export function getPixi(): PixiNamespace | null { + const pixi = ( window as unknown as { PIXI?: PixiNamespace } ).PIXI; + return pixi ?? null; +} diff --git a/src/plugins/pulse-monitor/readout.ts b/src/plugins/pulse-monitor/readout.ts new file mode 100644 index 00000000..cff85cf2 --- /dev/null +++ b/src/plugins/pulse-monitor/readout.ts @@ -0,0 +1,285 @@ +/** + * Pulse Monitor — the vitals readout and the beeper. + * + * The readout is plain DOM over the canvas (crisp text, no Pixi text + * atlas) and carries measured numbers only: requests per minute, ping + * (median response time), the share of requests that failed, and + * front-end visits per minute from the server feed. On the wallpaper + * it is decorative chrome under the desk, so it ignores the pointer; a + * `role="img"` with an `aria-label` gives assistive tech the same + * numbers plus the diagnosed rhythm, without live-region chatter. + * Where it can be reached — the widget card, the reserved band — it is + * a button that opens the Issues window. + */ + +import { __, sprintf } from '../../i18n'; +import { subscribeWorkArea, workAreaInsetsOf } from '../../work-area'; +import { rhythmLabel, type HealthReading } from './health'; + +export interface ReadoutData { + reading: HealthReading; + lineColor: string; + errorColor: string; +} + +export interface Readout { + update( data: ReadoutData ): void; + setVisible( visible: boolean ): void; + destroy(): void; +} + +const STYLE_ID = 'os-pulse-readout-style'; + +function ensureStyle(): void { + if ( document.getElementById( STYLE_ID ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = STYLE_ID; + style.textContent = ` +/* Doubled class: out-specifies the wallpaper layer's fill-the-layer child rule. */ +.os-pulse-readout.os-pulse-readout { + position: absolute; + /* The work-area bands chrome floats over (a bottom dock pill) are + added in by createReadout(), so the numbers stay uncovered. */ + inset-block: auto calc( 28px + var( --os-pulse-inset-block-end, 0px ) ); + inset-inline: auto calc( 28px + var( --os-pulse-inset-inline-end, 0px ) ); + width: auto; + height: auto; + z-index: 1; + display: flex; + gap: 18px; + align-items: baseline; + pointer-events: none; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; + line-height: 1; + color: var(--os-pulse-color); + opacity: 0.85; +} +.os-pulse-readout__value { font-size: 18px; font-weight: 600; } +.os-pulse-readout__unit { font-size: 9px; opacity: 0.6; margin-inline-start: 3px; letter-spacing: 0.06em; } +/* Response time: a clock before the number says what the ms measure. */ +.os-pulse-readout__icon.dashicons { + inline-size: 1em; + block-size: 1em; + font-size: 0.8em; + line-height: 1; + vertical-align: -0.05em; + margin-inline-end: 4px; + opacity: 0.75; + color: inherit; +} +.os-pulse-readout--alarm { color: var(--os-pulse-alarm); } +.os-pulse-readout.os-pulse-readout--compact { inset-block: auto 6px; inset-inline: 8px auto; gap: 12px; } +.os-pulse-readout--compact .os-pulse-readout__value { font-size: 13px; } +.os-pulse-readout.os-pulse-readout--band { + inset-block: auto 6px; + inset-inline: auto 10px; + gap: 12px; + padding: 2px 6px; + border-radius: 6px; + /* Spike labels pass behind the numbers in a short band. */ + background: rgb(0 0 0 / 0.6); + z-index: 2; +} +.os-pulse-readout--band .os-pulse-readout__value { font-size: 13px; } +/* Same doubled class as the base rule, so it wins the pointer back. */ +.os-pulse-readout.os-pulse-readout--interactive { + pointer-events: auto; + cursor: pointer; + border-radius: 6px; +} +.os-pulse-readout--interactive:hover { background: rgb(255 255 255 / 0.06); opacity: 1; } +.os-pulse-readout--interactive:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.os-pulse-readout--compact .os-pulse-readout__unit { font-size: 8px; margin-inline-start: 2px; } +`; + document.head.appendChild( style ); +} + +/** The four vitals, in display order: key + unit label. */ +/** Each number: key, unit, and what it means (the hover title). */ +function vitals(): Array< [ string, string, string ] > { + return [ + [ 'req', __( 'req/min' ), __( 'Requests this browser saw finish in the last minute.' ) ], + [ + 'ping', + __( 'ms' ), + __( 'Median response time over the last minute: half the requests were faster, half slower. The whole round trip, server work included.' ), + ], + [ 'err', __( 'errors' ), __( 'Share of those requests that failed (4xx, 5xx, no response).' ) ], + [ 'visits', __( 'visits/min' ), __( 'Front-end page views across the whole site, from the server feed.' ) ], + ]; +} + +/** Where the readout sits, and whether it can be clicked. */ +export type ReadoutMode = 'wallpaper' | 'band' | 'widget'; + +/** + * @param container Element to append the readout to. + * @param mode `wallpaper`: bottom of the work area, clear of a + * docked pill. `band`: the reserved top band's + * bottom-right corner. `widget`: small type along the + * card's bottom edge. + * @param onActivate When given, the readout is a button that calls it. + */ +export function createReadout( container: HTMLElement, mode: ReadoutMode = 'wallpaper', onActivate?: () => void ): Readout { + ensureStyle(); + const compact = mode === 'widget'; + const root = document.createElement( 'div' ); + root.className = [ + 'os-pulse-readout', + compact ? 'os-pulse-readout--compact' : '', + mode === 'band' ? 'os-pulse-readout--band' : '', + onActivate ? 'os-pulse-readout--interactive' : '', + ].filter( Boolean ).join( ' ' ); + root.setAttribute( 'role', onActivate ? 'button' : 'img' ); + if ( onActivate ) { + root.tabIndex = 0; + root.addEventListener( 'click', onActivate ); + root.addEventListener( 'keydown', ( e: KeyboardEvent ) => { + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + onActivate(); + } + } ); + } + const values: Record< string, HTMLElement > = {}; + for ( const [ key, unit, meaning ] of vitals() ) { + const cell = document.createElement( 'div' ); + cell.title = meaning; + const value = document.createElement( 'span' ); + value.className = 'os-pulse-readout__value'; + value.textContent = '--'; + if ( key === 'ping' ) { + const icon = document.createElement( 'span' ); + icon.className = 'os-pulse-readout__icon dashicons dashicons-clock'; + icon.setAttribute( 'aria-hidden', 'true' ); + cell.append( icon ); + } + const label = document.createElement( 'span' ); + label.className = 'os-pulse-readout__unit'; + label.textContent = unit; + cell.append( value, label ); + root.appendChild( cell ); + values[ key ] = value; + } + container.appendChild( root ); + + // The wallpaper fills the whole shell, docks included; keep the + // numbers inside the work area. The widget card and the band are + // already where nothing covers them. + let unsubscribe = (): void => undefined; + if ( mode === 'wallpaper' ) { + const place = (): void => { + const insets = workAreaInsetsOf( container ); + const rtl = getComputedStyle( container ).direction === 'rtl'; + root.style.setProperty( '--os-pulse-inset-block-end', `${ Math.max( 0, insets.bottom ) }px` ); + root.style.setProperty( '--os-pulse-inset-inline-end', `${ Math.max( 0, rtl ? insets.left : insets.right ) }px` ); + }; + place(); + unsubscribe = subscribeWorkArea( place ); + } + + return { + update( data: ReadoutData ): void { + const { reading } = data; + root.style.setProperty( '--os-pulse-color', data.lineColor ); + root.style.setProperty( '--os-pulse-alarm', data.errorColor ); + root.classList.toggle( 'os-pulse-readout--alarm', reading.state === 'critical' || reading.state === 'down' ); + + values.req.textContent = String( reading.requestsPerMinute ); + values.ping.textContent = reading.latencyP50 === null ? '--' : String( Math.round( reading.latencyP50 ) ); + values.err.textContent = + reading.successRate === null ? '--' : `${ Math.round( ( 1 - reading.successRate ) * 100 ) }%`; + values.visits.textContent = reading.visitsPerMinute === null ? '--' : String( reading.visitsPerMinute ); + + const summary = sprintf( + /* translators: 1: rhythm name, 2: requests per minute, 3: health score out of 100. */ + __( 'Site pulse: %1$s, %2$d requests per minute, health %3$d of 100' ), + rhythmLabel( reading.state ), + reading.requestsPerMinute, + reading.score, + ); + root.setAttribute( + 'aria-label', + onActivate + ? /* translators: %s: the site pulse summary. */ sprintf( __( '%s. Show issues' ), summary ) + : summary, + ); + }, + setVisible( visible: boolean ): void { + root.hidden = ! visible; + }, + destroy(): void { + unsubscribe(); + root.remove(); + }, + }; +} + +/** + * Monitor beeps over Web Audio. Nothing is created while sound is off: + * an `AudioContext` holds the output device open for as long as it + * lives, so the context only exists between "sound on" and "sound + * off". Browsers start one suspended until a user gesture (autoplay + * policy), so with sound on the first click or key press resumes it; + * until then beeps are silently skipped. + */ +export class Beeper { + private ctx: AudioContext | null = null; + private enabled = false; + private readonly unlock = (): void => { + if ( ! this.enabled ) { + return; + } + if ( ! this.ctx ) { + const Ctor = + window.AudioContext ?? + ( window as unknown as { webkitAudioContext?: typeof AudioContext } ).webkitAudioContext; + if ( Ctor ) { + this.ctx = new Ctor(); + } + } + void this.ctx?.resume(); + }; + + /** Turn sound on (listen for the unlocking gesture) or off (release the device). */ + setEnabled( on: boolean ): void { + if ( on === this.enabled ) { + return; + } + this.enabled = on; + if ( on ) { + document.addEventListener( 'pointerdown', this.unlock, { capture: true, passive: true } ); + document.addEventListener( 'keydown', this.unlock, { capture: true, passive: true } ); + return; + } + document.removeEventListener( 'pointerdown', this.unlock, { capture: true } ); + document.removeEventListener( 'keydown', this.unlock, { capture: true } ); + void this.ctx?.close(); + this.ctx = null; + } + + beep( frequency: number, ms: number, gain = 0.05 ): void { + const ctx = this.ctx; + if ( ! ctx || ctx.state !== 'running' ) { + return; + } + const osc = ctx.createOscillator(); + const amp = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = frequency; + const t = ctx.currentTime; + amp.gain.setValueAtTime( 0, t ); + amp.gain.linearRampToValueAtTime( gain, t + 0.005 ); + amp.gain.exponentialRampToValueAtTime( 0.0001, t + ms / 1000 ); + osc.connect( amp ).connect( ctx.destination ); + osc.start( t ); + osc.stop( t + ms / 1000 + 0.02 ); + } + + destroy(): void { + this.setEnabled( false ); + } +} diff --git a/src/plugins/pulse-monitor/rhythm.ts b/src/plugins/pulse-monitor/rhythm.ts new file mode 100644 index 00000000..efe63720 --- /dev/null +++ b/src/plugins/pulse-monitor/rhythm.ts @@ -0,0 +1,415 @@ +/** + * Pulse Monitor — rhythm engine. + * + * Turns a stream of beat requests into an ECG signal the scene can + * sample at any monitor time. Pure (no DOM, no Pixi) and clock-driven + * by its caller, so the whole thing is unit-testable. + * + * Scheduling. Signals arrive in bursts — an admin page load fires + * fifty asset requests in a second — so beats queue: + * + * - Major beats (`qrs`, `double`, `pvc`) start at most one per + * refractory gap. A queue that overflows boosts the next beats' + * height instead of growing forever: a burst reads as a run of + * tall, fast beats. + * - Minor beats (`flutter`, `blip`) ride on top, with their own, + * much shorter gap. + * - With no activity a resting beat fires every 60/BPM seconds, and + * every activity beat resets that clock — a pacemaker, not a + * metronome. Sustained activity raises the resting rate. + * + * Condition. {@link Rhythm.setState} switches the morphology to the + * diagnosed site health (see `health.ts`). + */ + +import type { HealthState } from './health'; +import { shapeFor, type BeatShape } from './sources'; + +export interface BeatRequest { + /** Source id (see `sources.ts`) — picks the default shape and height. */ + source: string; + /** Draw as a failed (ectopic) beat. */ + error?: boolean; + /** Override the source's default height. */ + amplitude?: number; + /** + * Why this beat failed, in a few words ("404", "network error", + * "TypeError: x is undefined"). Printed after the spike's label. + */ + detail?: string; +} + +/** What the scene gets told when a beat starts. */ +export interface BeatStarted { + source: string; + shape: BeatShape | 'vt'; + error: boolean; + major: boolean; + /** How long the complex lasts on the trace, ms. */ + duration: number; + /** + * Why the beat is drawn as failed: the signal's own detail, or — + * for an ectopic or tachycardic beat the diagnosis produced — the + * condition's leading reason. Empty for a normal beat. + */ + detail: string; + /** + * The diagnosis, not the signal, made this beat abnormal: a strained + * heart's PVC or a critical heart's ventricular tachycardia. + */ + ectopic: boolean; +} + +type Wave = ReadonlyArray< readonly [ number, number, number ] >; + +/** + * Morphologies as sums of Gaussians: `[ amplitude, centre ms, sigma ms ]`. + * Tuned by eye against a textbook lead-II trace. + */ +const WAVES: Record< BeatShape | 'vt' | 'qrsNoP', Wave > = { + qrs: [ + [ 0.11, 90, 20 ], // P + [ -0.12, 180, 8 ], // Q + [ 1, 200, 9 ], // R + [ -0.3, 222, 9 ], // S + [ 0.28, 390, 38 ], // T + ], + qrsNoP: [ + [ -0.12, 60, 8 ], + [ 1, 80, 9 ], + [ -0.3, 102, 9 ], + [ 0.24, 260, 36 ], + ], + double: [ + [ 0.95, 40, 9 ], + [ -0.22, 60, 8 ], + [ 0.7, 200, 9 ], + [ -0.18, 220, 8 ], + [ 0.22, 370, 40 ], + ], + flutter: [ + [ 1, 30, 8 ], + [ -0.3, 50, 8 ], + ], + blip: [ [ 1, 10, 5 ] ], + pvc: [ + [ -1, 70, 26 ], + [ 0.25, 140, 20 ], + [ 0.5, 290, 45 ], + ], + vt: [ + [ 1, 100, 40 ], + [ -0.75, 215, 40 ], + ], +}; + +/** How long each morphology lasts (its last Gaussian's centre + 3.5σ). */ +const DURATIONS = Object.fromEntries( + Object.entries( WAVES ).map( ( [ key, wave ] ) => [ + key, + Math.max( ...wave.map( ( [ , mu, sigma ] ) => mu + sigma * 3.5 ) ), + ] ), +) as Record< keyof typeof WAVES, number >; + +interface ActiveBeat { + start: number; + wave: Wave; + duration: number; + amp: number; + error: boolean; + /** ST-segment elevation (strained hearts). */ + st: number; +} + +interface Pending { + at: number; + beat: BeatRequest; +} + +const MAJOR: ReadonlySet< BeatShape > = new Set( [ 'qrs', 'double', 'pvc' ] ); + +/** Shortest gap between two major beats, ms (≈ 200 bpm). */ +const MAJOR_GAP = 300; +/** Shortest gap between two minor beats, ms. */ +const MINOR_GAP = 45; +const MAJOR_QUEUE_CAP = 24; +const MINOR_QUEUE_CAP = 48; +/** + * Height of a resting (pacemaker) beat. Kept well under an activity + * beat's so every request, page load and visitor reads as its own + * tall spike rather than blending into the idle rhythm. + */ +const RESTING_AMPLITUDE = 0.4; +/** Window for the activity rate, ms. */ +const RATE_WINDOW = 30000; + +function gaussianSum( wave: Wave, t: number ): number { + let v = 0; + for ( const [ a, mu, sigma ] of wave ) { + const d = t - mu; + v += a * Math.exp( -( d * d ) / ( 2 * sigma * sigma ) ); + } + return v; +} + +export interface RhythmOptions { + restingBpm: number; + /** Random source, injectable for tests. */ + random?: () => number; + /** Called when a beat starts (for sound, the readout, plugins). */ + onBeat?: ( beat: BeatStarted ) => void; +} + +export class Rhythm { + private restingBpm: number; + private state: HealthState = 'healthy'; + private reason = ''; + private readonly random: () => number; + private readonly onBeat?: ( beat: BeatStarted ) => void; + + private pending: Pending[] = []; + private majorQueue: BeatRequest[] = []; + private minorQueue: BeatRequest[] = []; + private active: ActiveBeat[] = []; + private lastMajor = -Infinity; + private lastMinor = -Infinity; + private nextRest = 0; + private pressure = 0; + private events: number[] = []; + private majors: number[] = []; + private now = 0; + + constructor( opts: RhythmOptions ) { + this.restingBpm = opts.restingBpm; + this.random = opts.random ?? Math.random; + this.onBeat = opts.onBeat; + // First resting beat straight away; none at all for a flatline. + this.nextRest = this.restingBpm > 0 ? 0 : Infinity; + } + + setRestingBpm( bpm: number ): void { + this.restingBpm = bpm; + this.scheduleRest( this.lastMajor ); + } + + /** + * Switch the morphology to a diagnosed condition. `reason` is the + * diagnosis's leading cause; beats the condition turns ectopic or + * tachycardic carry it as their detail. + */ + setState( state: HealthState, reason = '' ): void { + this.reason = reason; + if ( state === this.state ) { + return; + } + this.state = state; + this.scheduleRest( this.lastMajor ); + } + + getState(): HealthState { + return this.state; + } + + /** + * Queue a beat. `at` (monitor ms) delays it — used to replay a + * Heartbeat batch of server events with their real spacing. + */ + push( beat: BeatRequest, at?: number ): void { + if ( at !== undefined && at > this.now ) { + this.pending.push( { at, beat } ); + return; + } + this.enqueue( beat ); + } + + private enqueue( beat: BeatRequest ): void { + this.events.push( this.now ); + if ( this.state === 'down' ) { + // Asystole: the signal is counted, but nothing conducts. + return; + } + const { shape } = shapeFor( beat.source ); + if ( MAJOR.has( shape ) || beat.error ) { + if ( this.majorQueue.length >= MAJOR_QUEUE_CAP ) { + this.majorQueue.shift(); + this.pressure = Math.min( 0.6, this.pressure + 0.05 ); + } + this.majorQueue.push( beat ); + } else if ( this.minorQueue.length < MINOR_QUEUE_CAP ) { + this.minorQueue.push( beat ); + } + } + + /** Effective resting rate: the setting, raised by activity and by the condition. */ + private effectiveBpm(): number { + const perMinute = ( this.events.length * 60000 ) / RATE_WINDOW; + let bpm = this.restingBpm; + if ( bpm > 0 ) { + bpm += Math.min( 50, perMinute * 0.2 ); + } + switch ( this.state ) { + case 'strained': + return bpm > 0 ? bpm + 8 : 0; + case 'sick': + return Math.max( bpm, 50 ) + 25; + case 'critical': + return 175; + case 'down': + return 0; + default: + return bpm; + } + } + + private scheduleRest( from: number ): void { + const bpm = this.effectiveBpm(); + if ( bpm <= 0 ) { + this.nextRest = Infinity; + return; + } + const base = 60000 / bpm; + // Atrial fibrillation is "irregularly irregular"; a healthy + // heart still varies a few percent beat to beat. + const jitter = this.state === 'sick' ? 0.45 + this.random() * 0.9 : 0.96 + this.random() * 0.08; + this.nextRest = ( Number.isFinite( from ) ? from : this.now ) + base * jitter; + } + + private start( beat: BeatRequest | null, at: number ): void { + const source = beat?.source ?? 'rest'; + const def = shapeFor( source ); + let key: keyof typeof WAVES = def.shape; + let error = beat?.error === true || def.shape === 'pvc'; + const major = beat === null || MAJOR.has( def.shape ) || error; + let amp = beat?.amplitude ?? ( beat ? def.amplitude : RESTING_AMPLITUDE ); + let st = 0; + let detail = error ? beat?.detail ?? '' : ''; + let ectopic = false; + + if ( major ) { + if ( this.state === 'critical' ) { + key = 'vt'; + error = true; + ectopic = true; + detail = this.reason; + amp = Math.max( amp, 0.9 ); + } else if ( error ) { + key = 'pvc'; + } else if ( this.state === 'sick' && key === 'qrs' ) { + key = 'qrsNoP'; + } else if ( this.state === 'strained' ) { + st = 0.14; + // An ectopic beat now and then, with the compensatory + // pause that gives PVCs their tell-tale gap. + if ( key === 'qrs' && this.random() < 0.22 ) { + key = 'pvc'; + error = true; + ectopic = true; + detail = this.reason; + } + } + amp *= 1 + this.pressure; + this.pressure *= 0.9; + this.lastMajor = at; + this.majors.push( at ); + const pause = key === 'pvc' && ! beat?.error ? 1.6 : 1; + this.scheduleRest( at ); + if ( pause !== 1 && Number.isFinite( this.nextRest ) ) { + this.nextRest = at + ( this.nextRest - at ) * pause; + } + } else { + this.lastMinor = at; + } + + this.active.push( { + start: at, + wave: WAVES[ key ], + duration: DURATIONS[ key ], + amp, + error, + st, + } ); + this.onBeat?.( { source, shape: key === 'qrsNoP' ? 'qrs' : key, error, major, duration: DURATIONS[ key ], detail, ectopic } ); + } + + /** Advance the scheduler to monitor time `t` (ms). Call before {@link sample}. */ + step( t: number ): void { + this.now = t; + if ( this.pending.length ) { + const due = this.pending.filter( ( p ) => p.at <= t ); + if ( due.length ) { + this.pending = this.pending.filter( ( p ) => p.at > t ); + for ( const p of due ) { + this.enqueue( p.beat ); + } + } + } + + const majorGap = this.state === 'sick' ? MAJOR_GAP * ( 0.7 + this.random() * 0.9 ) : MAJOR_GAP; + if ( this.majorQueue.length && t - this.lastMajor >= majorGap ) { + this.start( this.majorQueue.shift() ?? null, t ); + } else if ( ! this.majorQueue.length && t >= this.nextRest && this.state !== 'down' ) { + this.start( null, t ); + } + if ( this.minorQueue.length && t - this.lastMinor >= MINOR_GAP ) { + this.start( this.minorQueue.shift() ?? null, t ); + } + + if ( this.active.length && this.active[ 0 ].start + this.active[ 0 ].duration < t ) { + this.active = this.active.filter( ( b ) => b.start + b.duration >= t ); + } + const cutoff = t - RATE_WINDOW; + while ( this.events.length && this.events[ 0 ] < cutoff ) { + this.events.shift(); + } + while ( this.majors.length && this.majors[ 0 ] < t - 15000 ) { + this.majors.shift(); + } + } + + /** + * Signal value at monitor time `t`, in R-wave units (≈ −1…1.5), + * plus whether a failed beat is what is being drawn there. + */ + sample( t: number ): { value: number; error: boolean } { + let value = 0; + let error = false; + for ( const beat of this.active ) { + const local = t - beat.start; + if ( local < 0 || local > beat.duration ) { + continue; + } + let v = gaussianSum( beat.wave, local ) * beat.amp; + if ( beat.st ) { + // A raised plateau between S and T. + const d = local - 300; + v += beat.st * beat.amp * Math.exp( -( d * d ) / ( 2 * 60 * 60 ) ); + } + if ( beat.error && Math.abs( v ) > 0.03 ) { + error = true; + } + value += v; + } + + // Baseline: breathing wander, plus a fibrillating quiver for a + // sick heart, plus a whisper of noise so it never looks drawn + // with a ruler. + const down = this.state === 'down'; + value += ( down ? 0.2 : 1 ) * ( 0.035 * Math.sin( ( t / 4200 ) * Math.PI * 2 ) + 0.015 * Math.sin( ( t / 1700 ) * Math.PI * 2 + 1.3 ) ); + if ( this.state === 'sick' ) { + value += 0.05 * Math.sin( ( t / 150 ) * Math.PI * 2 ) + 0.03 * Math.sin( ( t / 113 ) * Math.PI * 2 + 1 ); + } + const noise = this.state === 'strained' || this.state === 'critical' ? 0.03 : 0.015; + value += ( this.random() - 0.5 ) * noise; + return { value, error }; + } + + /** Heart rate as a monitor would print it: full beats in the last 15 s × 4. */ + heartRate(): number { + return this.majors.length * 4; + } + + /** Signals per minute over the rate window. */ + eventsPerMinute(): number { + return Math.round( ( this.events.length * 60000 ) / RATE_WINDOW ); + } +} diff --git a/src/plugins/pulse-monitor/scene.ts b/src/plugins/pulse-monitor/scene.ts new file mode 100644 index 00000000..548bccce --- /dev/null +++ b/src/plugins/pulse-monitor/scene.ts @@ -0,0 +1,605 @@ +/** + * Pulse Monitor — PixiJS scene. + * + * Draws the trace a {@link Rhythm} produces: one value per 2-px + * column, written by a head that moves at `speed` px/s. Two layouts: + * + * - `sweep` the head crosses the desk and wraps, erasing a short + * gap ahead of itself — the classic bedside monitor. + * - `scroll` the newest sample sits at the right edge and the whole + * trace flows left. + * + * Older samples fade like phosphor. Failed beats are drawn in the + * error colour. Faint "echo" traces — delayed, attenuated copies of + * the main one — fill the rest of the desk, and an optional ECG-paper + * grid sits behind everything. + * + * The scene owns the monitor clock: it advances only while animating, + * so a hidden tab does not come back to a minute of queued beats + * drawn in one frame. + */ + +import type { PixiApp, PixiGraphics, PixiNamespace, PixiStrokeStyle } from './pixi-types'; +import type { Rhythm } from './rhythm'; +import { hexToNumber, type PulseSettings } from './settings'; + +export interface PulseSceneOptions { + container: HTMLElement; + pixi: PixiNamespace; + settings: PulseSettings; + prefersReducedMotion: boolean; + rhythm: Rhythm; + /** + * Which surface the trace fills: + * + * - `wallpaper` the whole desk. + * - `widget` a small card — taller beats, a slower sweep so a + * label stays readable, at most three echoes. + * - `preview` an OS Settings swatch — no labels, no glow + * filter, no head halo. + */ + variant?: 'wallpaper' | 'band' | 'widget' | 'preview'; +} + +export interface PulseScene { + /** Current monitor time, ms. Signals schedule delayed beats against it. */ + now(): number; + applySettings( settings: PulseSettings ): void; + /** + * Print `text` above the beat that starts at the current column + * (call from the rhythm's `onBeat`, which fires inside the scene's + * own step, so "current" is exact). The label anchors to the + * complex's peak and travels and fades with the trace. + */ + labelBeat( text: string, error: boolean, durationMs: number ): void; + setAnimating( animating: boolean ): void; + destroy(): void; +} + +/** Column width in CSS px. */ +const STEP = 2; +/** Blank gap ahead of the sweep head, CSS px. */ +const SWEEP_GAP = 28; +/** Phosphor fade steps. */ +const BUCKETS = 6; +/** Per-variant scale: beat height (share of the surface), sweep speed, echo cap. */ +const VARIANTS = { + wallpaper: { amp: 0.17, speed: 1, echoes: Infinity }, + widget: { amp: 0.3, speed: 0.55, echoes: 3 }, + // The reserved top band: a short, wide strip — taller beats, no + // echo lines (there is no room above and below for them). + band: { amp: 0.42, speed: 1, echoes: 0 }, + preview: { amp: 0.17, speed: 1, echoes: 3 }, +} as const; + +/** Echo traces sample every Nth column — they are faint; detail is wasted. */ +const ECHO_STRIDE = 3; +/** Same-text labels closer than this (CSS px) merge into "text ×N". */ +const LABEL_MERGE_PX = 70; +/** Labels closer than this (CSS px) stack upwards instead of overlapping. */ +const LABEL_STACK_PX = 90; +/** Live labels cap — a burst merges or drops rather than piling up DOM. */ +const LABEL_MAX = 40; + +interface SpikeLabel { + el: HTMLElement; + text: string; + count: number; + error: boolean; + /** Column of the complex's peak so far. */ + col: number; + /** Signal value at that peak. */ + peak: number; + /** Columns still to scan for the peak. */ + scan: number; + /** Value of `written` when the label was created. */ + born: number; + /** Stack row: 0 sits on the peak, 1+ above earlier neighbours. */ + row: number; +} + +const LABEL_STYLE_ID = 'os-pulse-labels-style'; + +function ensureLabelStyle(): void { + if ( document.getElementById( LABEL_STYLE_ID ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = LABEL_STYLE_ID; + style.textContent = ` +.os-pulse-labels { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +/* Physical left/top on purpose: a label is pinned to a canvas x, and the + canvas does not mirror in RTL. */ +.os-pulse-label { + position: absolute; + top: 0; + left: 0; + transform: translate( -50%, -100% ); + white-space: nowrap; + font: 600 10px/1 ui-monospace, "SF Mono", Menlo, Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + will-change: transform, opacity; +} +.os-pulse-label--below { transform: translate( -50%, 0 ); } +.os-pulse-labels--widget .os-pulse-label { font-size: 9px; letter-spacing: 0.04em; } +`; + document.head.appendChild( style ); +} + +export async function mountPulseScene( opts: PulseSceneOptions ): Promise< PulseScene > { + const { container, pixi, rhythm } = opts; + let settings = opts.settings; + const variant = opts.variant ?? 'wallpaper'; + const scale = VARIANTS[ variant ]; + const preview = variant === 'preview'; + /** Effective sweep speed, px/s. */ + const speed = (): number => settings.speed * scale.speed; + + const priorBackground = container.style.background; + container.style.background = settings.background; + + const app: PixiApp = new pixi.Application(); + try { + await app.init( { + resizeTo: container, + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + resolution: Math.min( window.devicePixelRatio || 1, 2 ), + } ); + } catch ( err ) { + container.style.background = priorBackground; + throw err; + } + + container.appendChild( app.canvas ); + app.canvas.style.position = 'absolute'; + app.canvas.style.inset = '0'; + app.canvas.style.width = '100%'; + app.canvas.style.height = '100%'; + app.canvas.style.pointerEvents = 'none'; + + const grid: PixiGraphics = new pixi.Graphics(); + const echoes: PixiGraphics = new pixi.Graphics(); + const glow: PixiGraphics = new pixi.Graphics(); + const trace: PixiGraphics = new pixi.Graphics(); + const head: PixiGraphics = new pixi.Graphics(); + app.stage.addChild( grid, echoes, glow, trace, head ); + + ensureLabelStyle(); + const labelLayer = document.createElement( 'div' ); + labelLayer.className = `os-pulse-labels os-pulse-labels--${ variant }`; + container.appendChild( labelLayer ); + let labels: SpikeLabel[] = []; + /** Columns written since mount — the label clock. */ + let written = 0; + + const blur = pixi.BlurFilter && ! preview ? new pixi.BlurFilter( { strength: 8, quality: 2 } ) : null; + + let width = 0; + let height = 0; + let cols = 0; + let values = new Float32Array( 0 ); + let errors = new Uint8Array( 0 ); + /** Next column to write. */ + let cursor = 0; + /** Sub-column px carried between frames. */ + let carry = 0; + let clock = 0; + let animating = false; + let destroyed = false; + + const lineColor = (): number => hexToNumber( settings.lineColor ); + const errorColor = (): number => hexToNumber( settings.errorColor ); + + function drawGrid(): void { + grid.clear(); + if ( ! settings.grid ) { + return; + } + const minor = 20; + const color = lineColor(); + for ( let i = 0, x = 0; x <= width; i++, x += minor ) { + grid.moveTo( x, 0 ).lineTo( x, height ).stroke( { color, width: 1, alpha: i % 5 === 0 ? 0.1 : 0.04 } ); + } + for ( let i = 0, y = 0; y <= height; i++, y += minor ) { + grid.moveTo( 0, y ).lineTo( width, y ).stroke( { color, width: 1, alpha: i % 5 === 0 ? 0.1 : 0.04 } ); + } + } + + /** + * Size the column buffer to the surface. A resize keeps the trace — + * a browser window dragged wider must not wipe the monitor. Sweep + * columns ARE screen positions, so they stay put (and the head with + * them); a narrower surface drops what fell off its edge. Scroll + * keeps its newest columns, still flowing in from the right. Only + * `fresh` (a style switch, where the two layouts would read the same + * buffer differently) starts over. + */ + function allocate( fresh = false ): void { + const prevValues = values; + const prevErrors = errors; + const prevCols = cols; + const prevCursor = cursor; + width = app.screen.width; + height = app.screen.height; + cols = Math.max( 2, Math.ceil( width / STEP ) + 1 ); + values = new Float32Array( cols ).fill( Number.NaN ); + errors = new Uint8Array( cols ); + if ( fresh || prevCols === 0 ) { + cursor = 0; + carry = 0; + clearLabels(); + } else if ( settings.style === 'sweep' ) { + const keep = Math.min( prevCols, cols ); + values.set( prevValues.subarray( 0, keep ) ); + errors.set( prevErrors.subarray( 0, keep ) ); + cursor = prevCursor < cols ? prevCursor : 0; + labels = labels.filter( ( label ) => { + if ( label.col < cols ) { + return true; + } + label.el.remove(); + return false; + } ); + } else { + const keep = Math.min( prevCols, cols - 1 ); + for ( let k = 0; k < keep; k++ ) { + const from = ( prevCursor - keep + k + prevCols ) % prevCols; + values[ k ] = prevValues[ from ]; + errors[ k ] = prevErrors[ from ]; + } + cursor = keep % cols; + labels = labels.filter( ( label ) => { + const age = ( prevCursor - label.col + prevCols ) % prevCols; + if ( age <= keep ) { + label.col = ( cursor - age + cols ) % cols; + return true; + } + label.el.remove(); + return false; + } ); + } + drawGrid(); + } + + function clearLabels(): void { + for ( const label of labels ) { + label.el.remove(); + } + labels = []; + } + + function labelBeat( text: string, error: boolean, durationMs: number ): void { + if ( ! settings.labels || preview ) { + return; + } + const last = labels[ labels.length - 1 ]; + const gapPx = last ? ( written - last.born ) * STEP : Infinity; + if ( last && last.text === text && last.error === error && gapPx < LABEL_MERGE_PX ) { + last.count++; + last.el.textContent = `${ text } ×${ last.count }`; + return; + } + if ( labels.length >= LABEL_MAX ) { + labels.shift()?.el.remove(); + } + const el = document.createElement( 'span' ); + el.className = 'os-pulse-label'; + el.textContent = text; + labelLayer.appendChild( el ); + labels.push( { + el, + text, + count: 1, + error, + col: cursor, + peak: 0, + scan: Math.max( 1, Math.ceil( ( ( durationMs / 1000 ) * speed() ) / STEP ) ), + born: written, + row: last && gapPx < LABEL_STACK_PX ? Math.min( 2, last.row + 1 ) : 0, + } ); + } + + /** Place every label on its peak; retire the ones the trace has erased. */ + function drawLabels(): void { + if ( ! labels.length ) { + return; + } + const by = baseY(); + const amp = ampPx(); + const gap = settings.style === 'sweep' ? Math.ceil( SWEEP_GAP / STEP ) : 0; + const normal = settings.lineColor; + const failed = settings.errorColor; + const keep: SpikeLabel[] = []; + for ( const label of labels ) { + const age = written - label.born; + if ( ! settings.labels || age >= cols - gap - 2 ) { + label.el.remove(); + continue; + } + keep.push( label ); + const j = ( label.col - cursor + cols ) % cols; + const x = xFor( label.col, j ); + const below = label.peak < -0.05; + const peakY = Math.min( height - 6, Math.max( 6, by - label.peak * amp ) ); + // Kept inside the surface: in a short band a tall spike's + // label would otherwise ride off the top edge. A label sits + // on its `y` (translate -100%), so 14px is its own height. + const y = below + ? Math.min( height - 2, peakY + 8 + label.row * 13 ) + : Math.max( 14, peakY - 8 - label.row * 13 ); + label.el.classList.toggle( 'os-pulse-label--below', below ); + label.el.style.left = `${ x }px`; + label.el.style.top = `${ y }px`; + label.el.style.color = label.error ? failed : normal; + label.el.style.opacity = String( 0.25 + 0.75 * Math.pow( j / cols, 1.4 ) ); + } + labels = keep; + } + + const baseY = (): number => ( height * settings.baseline ) / 100; + const ampPx = (): number => height * scale.amp * settings.intensity; + + /** Column index → x, given its position `j` in oldest-first order. */ + function xFor( idx: number, j: number ): number { + return settings.style === 'sweep' ? idx * STEP : width - ( cols - 1 - j ) * STEP; + } + + function write( value: number, error: boolean ): void { + for ( const label of labels ) { + if ( label.scan > 0 ) { + label.scan--; + if ( Math.abs( value ) > Math.abs( label.peak ) ) { + label.peak = value; + label.col = cursor; + } + } + } + written++; + values[ cursor ] = value; + errors[ cursor ] = error ? 1 : 0; + cursor = ( cursor + 1 ) % cols; + if ( settings.style === 'sweep' ) { + const gap = Math.ceil( SWEEP_GAP / STEP ); + for ( let k = 0; k < gap; k++ ) { + values[ ( cursor + k ) % cols ] = Number.NaN; + } + } + } + + /** + * Stroke the buffer oldest → newest as runs of constant style: + * a run breaks where the fade bucket or the error flag changes, + * and on blank (NaN) columns. + */ + function strokeTrace( g: PixiGraphics, widthPx: number, alphaScale: number ): void { + const by = baseY(); + const amp = ampPx(); + const min = 6; + const max = height - 6; + const normal = lineColor(); + const failed = errorColor(); + let open = false; + let runKey = -1; + let style: PixiStrokeStyle = { color: normal, width: widthPx }; + const styleFor = ( bucket: number, err: number ): PixiStrokeStyle => ( { + color: err ? failed : normal, + width: widthPx, + alpha: alphaScale * ( 0.32 + 0.68 * Math.pow( ( bucket + 1 ) / BUCKETS, 1.4 ) ), + cap: 'round', + join: 'round', + } ); + for ( let j = 0; j < cols; j++ ) { + const idx = ( cursor + j ) % cols; + const v = values[ idx ]; + if ( Number.isNaN( v ) ) { + if ( open ) { + g.stroke( style ); + open = false; + } + continue; + } + // The sweep buffer wraps at the right edge: lift the pen + // rather than draw a line back across the desk. + if ( open && idx === 0 && settings.style === 'sweep' ) { + g.stroke( style ); + open = false; + } + const x = xFor( idx, j ); + const y = Math.min( max, Math.max( min, by - v * amp ) ); + const bucket = Math.min( BUCKETS - 1, Math.floor( ( j / cols ) * BUCKETS ) ); + const key = bucket * 2 + errors[ idx ]; + if ( ! open ) { + g.moveTo( x, y ); + open = true; + runKey = key; + style = styleFor( bucket, errors[ idx ] ); + } else if ( key !== runKey ) { + g.lineTo( x, y ); + g.stroke( style ); + g.moveTo( x, y ); + runKey = key; + style = styleFor( bucket, errors[ idx ] ); + } else { + g.lineTo( x, y ); + } + } + if ( open ) { + g.stroke( style ); + } + } + + function drawEchoes(): void { + echoes.clear(); + const count = Math.min( settings.echoes, scale.echoes ); + if ( ! count ) { + return; + } + const by = baseY(); + const amp = ampPx(); + const spacing = height / ( count + 1 ); + for ( let e = 1; e <= count; e++ ) { + const ey = e * spacing; + const dy = Math.abs( ey - by ); + if ( dy < 14 ) { + continue; + } + // Echoes near the trace carry more of its shape. + const echoScale = 0.22 * Math.exp( -dy / ( height * 0.3 ) ); + const lag = e * 11; + let open = false; + for ( let j = 0; j < cols; j += ECHO_STRIDE ) { + const idx = ( cursor + j ) % cols; + const v = values[ ( idx - lag + cols * 2 ) % cols ]; + if ( Number.isNaN( v ) || ( idx < ECHO_STRIDE && settings.style === 'sweep' ) ) { + open = false; + continue; + } + const x = xFor( idx, j ); + const y = ey - v * amp * echoScale; + if ( open ) { + echoes.lineTo( x, y ); + } else { + echoes.moveTo( x, y ); + open = true; + } + } + echoes.stroke( { color: 0xffffff, width: 1, alpha: 0.07 } ); + } + } + + function drawHead(): void { + head.clear(); + const idx = ( cursor - 1 + cols ) % cols; + const v = values[ idx ]; + if ( Number.isNaN( v ) ) { + return; + } + const x = xFor( idx, cols - 1 ); + const y = Math.min( height - 6, Math.max( 6, baseY() - v * ampPx() ) ); + const color = errors[ idx ] ? errorColor() : lineColor(); + if ( ! preview ) { + head.circle( x, y, settings.lineWidth * 6 ).fill( { color, alpha: 0.06 + settings.glow * 0.12 } ); + } + head.circle( x, y, settings.lineWidth * 1.4 ).fill( { color, alpha: 1 } ); + } + + function redraw(): void { + trace.clear(); + glow.clear(); + strokeTrace( trace, settings.lineWidth, 1 ); + if ( settings.glow > 0 ) { + strokeTrace( glow, settings.lineWidth * 4, settings.glow * 0.45 ); + } + glow.filters = blur && settings.glow > 0 ? [ blur ] : null; + drawEchoes(); + drawHead(); + drawLabels(); + } + + /** Advance the monitor by `dt` ms, writing the columns the head crossed. */ + function advance( dt: number ): void { + const t0 = clock; + clock += dt; + carry += ( speed() * dt ) / 1000; + const n = Math.min( cols, Math.floor( carry / STEP ) ); + carry -= Math.floor( carry / STEP ) * STEP; + if ( n === 0 ) { + rhythm.step( clock ); + return; + } + for ( let k = 1; k <= n; k++ ) { + const t = t0 + ( dt * k ) / n; + rhythm.step( t ); + const s = rhythm.sample( t ); + write( s.value, s.error ); + } + } + + const tick = ( ticker: { deltaMS: number } ): void => { + if ( app.screen.width !== width || app.screen.height !== height ) { + allocate(); + } + advance( Math.min( ticker.deltaMS, 100 ) ); + redraw(); + }; + + allocate(); + + // Pixi's `resizeTo` only re-measures on a BROWSER resize; a widget + // card or the reserved band changes size on its own. + const sizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver( () => { + if ( destroyed ) { + return; + } + app.resize?.(); + if ( opts.prefersReducedMotion && ( app.screen.width !== width || app.screen.height !== height ) ) { + allocate(); + redraw(); + app.render(); + } + } ); + sizeObserver?.observe( container ); + + if ( opts.prefersReducedMotion ) { + // One still frame: a full desk of resting rhythm, no movement. + const msPerScreen = ( width / speed() ) * 1000; + const frames = Math.ceil( msPerScreen / 16 ); + for ( let i = 0; i < frames; i++ ) { + advance( 16 ); + } + redraw(); + app.ticker.stop(); + app.render(); + } else { + app.ticker.add( tick ); + animating = true; + } + + return { + now: () => clock, + labelBeat, + applySettings( next: PulseSettings ): void { + const restyled = next.style !== settings.style; + settings = next; + container.style.background = settings.background; + if ( restyled ) { + allocate( true ); + } else { + drawGrid(); + } + if ( opts.prefersReducedMotion ) { + redraw(); + app.render(); + } + }, + setAnimating( next: boolean ): void { + if ( destroyed || opts.prefersReducedMotion || next === animating ) { + return; + } + animating = next; + if ( next ) { + app.ticker.start(); + } else { + app.ticker.stop(); + } + }, + destroy(): void { + if ( destroyed ) { + return; + } + destroyed = true; + sizeObserver?.disconnect(); + app.ticker.remove( tick ); + clearLabels(); + labelLayer.remove(); + app.destroy( { removeView: true }, { children: true } ); + container.style.background = priorBackground; + }, + }; +} diff --git a/src/plugins/pulse-monitor/settings.ts b/src/plugins/pulse-monitor/settings.ts new file mode 100644 index 00000000..1852b1c7 --- /dev/null +++ b/src/plugins/pulse-monitor/settings.ts @@ -0,0 +1,182 @@ +/** + * Pulse Monitor — user-tunable settings. + * + * Values persist through the shell's per-wallpaper settings surface + * (`ctx.settings` / `ctx.setSettings`), which only round-trips scalars + * and caps each wallpaper at 32 keys — so the per-source on/off + * switches travel as ONE comma-separated `muted` string rather than + * nineteen booleans. {@link sanitizePulseSettings} clamps every field + * back into range and falls back to the defaults for anything missing + * or malformed. + */ + +import { SOURCE_IDS, type SourceId } from './sources'; + +export type PulseStyle = 'sweep' | 'scroll'; + +/** + * `full`: the trace runs across the whole wallpaper, under the desk. + * `band`: the shell reserves a strip along the top for the trace and + * its numbers; no window, icon or widget can be placed there. + */ +export type PulseLayout = 'full' | 'band'; + +export interface PulseSettings { + /** `sweep`: a write head crosses the screen and wraps. `scroll`: the trace flows left. */ + style: PulseStyle; + /** Whole wallpaper, or a reserved band along the top (wallpaper only). */ + layout: PulseLayout; + /** Height of the reserved band, CSS px. */ + bandHeight: number; + /** Trace colour (hex). */ + lineColor: string; + /** Backdrop colour (hex). */ + background: string; + /** Colour of failed (ectopic) beats and the alarm readout (hex). */ + errorColor: string; + /** Horizontal speed of the trace, CSS px per second. */ + speed: number; + /** Beat height multiplier. */ + intensity: number; + /** Trace stroke width, CSS px. */ + lineWidth: number; + /** Phosphor glow strength, 0–1. */ + glow: number; + /** Baseline height as a percentage of the desk, from the top. */ + baseline: number; + /** Faint ghost traces above and below the main one. */ + echoes: number; + /** ECG paper grid behind the trace. */ + grid: boolean; + /** Requests/min, ping, errors and visits/min in the corner. */ + readout: boolean; + /** Print what caused each spike ("request", "visit", …) above it. */ + labels: boolean; + /** A short beep on every full beat, and the alarm when the site goes down. */ + sound: boolean; + /** + * Resting heart rate (beats per minute) the trace keeps when the + * site is quiet. Activity beats reset it like a pacemaker would. + */ + restingBpm: number; + /** Read the site's health into the rhythm. */ + diagnose: boolean; + /** Sources the user switched off. */ + muted: SourceId[]; +} + +export const PULSE_DEFAULTS: PulseSettings = { + style: 'sweep', + layout: 'full', + bandHeight: 88, + lineColor: '#ff2a2a', + background: '#000000', + errorColor: '#ffb020', + speed: 220, + intensity: 1, + lineWidth: 2, + glow: 0.6, + baseline: 58, + echoes: 10, + grid: false, + readout: true, + labels: true, + sound: false, + restingBpm: 62, + diagnose: true, + muted: [], +}; + +/** Slider bounds for the config dialog — also the sanitizer clamps. */ +export const PULSE_LIMITS = { + bandHeight: { min: 56, max: 300 }, + speed: { min: 60, max: 600 }, + intensity: { min: 0.3, max: 2 }, + lineWidth: { min: 1, max: 6 }, + glow: { min: 0, max: 1 }, + baseline: { min: 20, max: 80 }, + echoes: { min: 0, max: 24 }, + restingBpm: { min: 0, max: 120 }, +} as const; + +function clampNumber( + value: unknown, + limits: { min: number; max: number }, + fallback: number, +): number { + if ( typeof value !== 'number' || ! Number.isFinite( value ) ) { + return fallback; + } + return Math.min( limits.max, Math.max( limits.min, value ) ); +} + +function hex( value: unknown, fallback: string ): string { + return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test( value ) + ? value.toLowerCase() + : fallback; +} + +function bool( value: unknown, fallback: boolean ): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +/** Parse the persisted `muted` string (or array) into known source ids. */ +export function parseMuted( value: unknown ): SourceId[] { + let parts: unknown[] = []; + if ( typeof value === 'string' ) { + parts = value.split( ',' ); + } else if ( Array.isArray( value ) ) { + parts = value; + } + const known = new Set< string >( SOURCE_IDS ); + const out = new Set< SourceId >(); + for ( const part of parts ) { + const id = String( part ).trim(); + if ( known.has( id ) ) { + out.add( id as SourceId ); + } + } + return [ ...out ]; +} + +/** Serialise a muted list for `ctx.setSettings`. */ +export function serializeMuted( muted: readonly SourceId[] ): string { + return muted.join( ',' ); +} + +/** + * Coerce an untrusted settings bag into a fully populated + * {@link PulseSettings}. + */ +export function sanitizePulseSettings( + raw: Record< string, unknown > | undefined, +): PulseSettings { + const bag = raw ?? {}; + const d = PULSE_DEFAULTS; + return { + style: bag.style === 'scroll' || bag.style === 'sweep' ? bag.style : d.style, + layout: bag.layout === 'band' || bag.layout === 'full' ? bag.layout : d.layout, + bandHeight: Math.round( clampNumber( bag.bandHeight, PULSE_LIMITS.bandHeight, d.bandHeight ) ), + lineColor: hex( bag.lineColor, d.lineColor ), + background: hex( bag.background, d.background ), + errorColor: hex( bag.errorColor, d.errorColor ), + speed: clampNumber( bag.speed, PULSE_LIMITS.speed, d.speed ), + intensity: clampNumber( bag.intensity, PULSE_LIMITS.intensity, d.intensity ), + lineWidth: clampNumber( bag.lineWidth, PULSE_LIMITS.lineWidth, d.lineWidth ), + glow: clampNumber( bag.glow, PULSE_LIMITS.glow, d.glow ), + baseline: clampNumber( bag.baseline, PULSE_LIMITS.baseline, d.baseline ), + echoes: Math.round( clampNumber( bag.echoes, PULSE_LIMITS.echoes, d.echoes ) ), + grid: bool( bag.grid, d.grid ), + readout: bool( bag.readout, d.readout ), + labels: bool( bag.labels, d.labels ), + sound: bool( bag.sound, d.sound ), + restingBpm: Math.round( clampNumber( bag.restingBpm, PULSE_LIMITS.restingBpm, d.restingBpm ) ), + diagnose: bool( bag.diagnose, d.diagnose ), + muted: bag.muted === undefined ? [ ...d.muted ] : parseMuted( bag.muted ), + }; +} + +/** `#rrggbb` → `0xrrggbb` for Pixi. */ +export function hexToNumber( value: string ): number { + return parseInt( value.slice( 1 ), 16 ); +} diff --git a/src/plugins/pulse-monitor/signals.ts b/src/plugins/pulse-monitor/signals.ts new file mode 100644 index 00000000..eca095b9 --- /dev/null +++ b/src/plugins/pulse-monitor/signals.ts @@ -0,0 +1,734 @@ +/** + * Pulse Monitor — signal taps. + * + * Everything that can make the trace beat, wired in one place so the + * teardown is auditable. Wired once per page by the hub (`hub.ts`), + * however many monitors are on the desk. Two outputs per signal: + * + * - a beat, emitted to the hub, which fans it out to every mounted + * monitor (each applies its own muting); + * - an observation for the shared {@link HealthMonitor}. + * + * Failed beats carry a short `detail` — the status, the error message — + * which the trace prints after the spike's label. + * + * Taps, by source id: + * + * requests `os/request-settled` (every `wp.os.fetch`), + * `os.iframe.network-completed` (fetch/XHR inside every + * window's page, the block editor included), resource + * timing for pages without the bridge, and a + * `PerformanceObserver` on the shell for raw fetch/XHR + * that bypassed both. + * assets resource timing in the shell and in every same-origin + * window iframe: scripts, styles, images, fonts. + * pages `os.window.content-loaded`, and every window iframe's + * `load` (in-window links, form POSTs, redirects), with + * the page's own HTTP status when same-origin. + * heartbeat WordPress Heartbeat ticks (and its errors / lost + * connection, which feed the diagnosis). + * windows window lifecycle, drags, snaps, overview, desktops, + * widgets, commands, desktop icons. + * input clicks, key presses and wheel in the shell and windows. + * messages postMessage traffic into the shell, `wp.os.broadcast`, + * connection messages. + * notices toasts, notifications, badge changes. + * uploads file-drop uploads. + * errors JS errors and rejections (shell, windows, commands), + * failed resources, auth loss, going offline. + * server visitors, bots, comments, publishes, orders, logins, + * API, cron and other people's admin traffic, relayed by + * `includes/pulse-monitor.php` over Heartbeat. + * + * Plugins add beats by publishing on the `pulse/beat` activity channel. + */ + +import { activity } from '../../activity'; +import { heartbeat } from '../../heartbeat'; +import { addAction, removeAction, HOOKS } from '../../hooks'; +import { __, sprintf } from '../../i18n'; +import type { HealthMonitor, ServerHealth } from './health'; +import type { IncidentLog } from './incidents'; +import type { BeatRequest } from './rhythm'; + +/** Hook namespace for every listener registered here. */ +const NAMESPACE = 'desktop-mode/pulse'; + +/** Heartbeat field shared with `includes/pulse-monitor.php`. */ +const HEARTBEAT_FIELD = 'openstation_pulse'; + +/** How long a URL seen on the activity bus suppresses its resource-timing twin, ms. */ +const DEDUPE_MS = 5000; + +/** A shell `content-loaded` and an iframe `load` this close together are one page load, ms. */ +const PAGE_DEDUPE_MS = 1500; + +/** Fetch-like initiators — the rest of resource timing counts as assets. */ +const FETCH_INITIATORS = new Set( [ 'fetch', 'xmlhttprequest', 'beacon' ] ); + +/** Window-level shell hooks that each beat as a `windows` signal. */ +const WINDOW_HOOKS = [ + HOOKS.WINDOW_OPENED, + HOOKS.WINDOW_CLOSED, + HOOKS.WINDOW_FOCUSED, + HOOKS.WINDOW_MINIMIZED, + HOOKS.WINDOW_RESTORED, + HOOKS.WINDOW_MAXIMIZED, + HOOKS.WINDOW_UNMAXIMIZED, + HOOKS.WINDOW_FULLSCREEN_ENTERED, + HOOKS.WINDOW_FULLSCREEN_EXITED, + HOOKS.WINDOW_DRAG_END, + HOOKS.WINDOW_RESIZE_END, + HOOKS.WINDOW_RELOADED, + HOOKS.WINDOW_TITLE_CHANGED, + HOOKS.SNAP_ZONE_COMMITTED, + HOOKS.OVERVIEW_ENTERED, + HOOKS.OVERVIEW_EXITED, + HOOKS.DESKTOP_SWITCHED, + HOOKS.DESKTOP_ICON_CLICKED, + HOOKS.WIDGET_ADDED, + HOOKS.WIDGET_REMOVED, + HOOKS.COMMAND_AFTER_RUN, + HOOKS.CONSTELLATION_OPENED, +]; + +/** One server event, as the Heartbeat response carries it. */ +interface ServerEvent { + ts: number; + kind: string; + status: number; + error: boolean; +} + +interface ServerPayload { + seq?: number; + now?: number; + events?: ServerEvent[]; + health?: ServerHealth; +} + +interface JQueryish { + ( target: Document ): { + on( events: string, handler: ( ...args: unknown[] ) => void ): unknown; + off( events: string ): unknown; + }; +} + +interface WpHeartbeat { + interval( next?: number | string ): number; + connectNow?: () => void; +} + +export interface SignalOptions { + health: HealthMonitor; + /** Where every failure is filed with its URL, status and window, for the Issues window. */ + incidents: IncidentLog; + /** + * Hand a beat to the monitors. `delayMs` postpones it — used to + * replay a Heartbeat batch of server events at their real spacing. + */ + emit: ( beat: BeatRequest, delayMs: number ) => void; +} + +/** Trim a free-form error message to something that fits on a spike. */ +function shortDetail( text: unknown ): string { + const flat = String( text ?? '' ).replace( /\s+/g, ' ' ).trim(); + return flat.length > 48 ? `${ flat.slice( 0, 47 ) }…` : flat; +} + +/** Detail for an HTTP outcome: the status, or why there was none. */ +function statusDetail( status: number, fallback = 'network error' ): string { + return status > 0 ? String( status ) : fallback; +} + +/** A per-key throttle: true at most once every `ms`. */ +function throttle( ms: number ): ( key: string ) => boolean { + const last = new Map< string, number >(); + return ( key ) => { + const t = performance.now(); + if ( t - ( last.get( key ) ?? -Infinity ) < ms ) { + return false; + } + last.set( key, t ); + return true; + }; +} + +function absoluteUrl( url: string ): string { + try { + return new URL( url, window.location.href ).href; + } catch { + return url; + } +} + +/** + * Wire every tap. Returns the teardown. + */ +export function wireSignals( opts: SignalOptions ): () => void { + const { health, emit, incidents } = opts; + const now = (): number => performance.now(); + // A hidden tab is throttled: the browser delays timers and + // callbacks, so a duration measured then is the throttling, not the + // site. Dropping those samples keeps a backgrounded tab from + // diagnosing "slow responses" (and drawing amber PVCs) on its own. + const latency = ( ms: number ): void => { + if ( document.visibilityState === 'visible' ) { + health.recordLatency( now(), ms ); + } + }; + const disposers: Array< () => void > = []; + const gates = { + input: throttle( 70 ), + wheel: throttle( 160 ), + messages: throttle( 40 ), + bounds: throttle( 150 ), + }; + + const beat = ( + source: string, + extra: { error?: boolean; amplitude?: number; delay?: number; detail?: string } = {}, + ): void => { + emit( + { source, error: extra.error === true, amplitude: extra.amplitude, detail: extra.detail }, + extra.delay ?? 0, + ); + }; + + const on = < E extends Event >( + target: EventTarget, + type: string, + handler: ( e: E ) => void, + options: AddEventListenerOptions = { passive: true }, + into: Array< () => void > = disposers, + ): void => { + target.addEventListener( type, handler as EventListener, options ); + into.push( () => target.removeEventListener( type, handler as EventListener, options ) ); + }; + + const hook = ( name: string, handler: ( ...args: unknown[] ) => void ): void => { + const ns = `${ NAMESPACE }/${ name }`; + addAction( name, ns, handler ); + disposers.push( () => removeAction( name, ns ) ); + }; + + const channel = ( name: string, handler: ( payload: unknown ) => void ): void => { + disposers.push( activity.subscribe( name as 'os/request-settled', handler as ( p: unknown ) => void ) ); + }; + + // URLs the activity bus already reported, so resource timing does + // not beat for them twice. Consumed on match: two calls to the same + // URL are two beats. + const recent = new Map< string, number[] >(); + const remember = ( url: string ): void => { + const key = absoluteUrl( url ); + const list = recent.get( key ) ?? []; + list.push( performance.now() + DEDUPE_MS ); + recent.set( key, list ); + }; + const consume = ( url: string ): boolean => { + const list = recent.get( url ); + if ( ! list ) { + return false; + } + const t = performance.now(); + while ( list.length && list[ 0 ] < t ) { + list.shift(); + } + const hit = list.length > 0; + list.shift(); + if ( ! list.length ) { + recent.delete( url ); + } + return hit; + }; + + /* --------------------------------------------------------------- + * requests + * ------------------------------------------------------------- */ + + channel( 'os/request-settled', ( raw ) => { + const p = raw as { url?: string; status?: number; ok?: boolean; aborted?: boolean; error?: string }; + if ( p.url ) { + remember( p.url ); + } + if ( p.aborted ) { + return; + } + const status = typeof p.status === 'number' ? p.status : 0; + const detail = statusDetail( status, shortDetail( p.error ) || 'network error' ); + health.recordRequest( now(), status ); + beat( 'requests', { error: status === 0 || status >= 400, detail } ); + if ( status === 0 || status >= 400 ) { + const q = raw as { method?: string; windowId?: string | null }; + incidents.add( { source: 'requests', detail, status: status || undefined, method: q.method, url: p.url, windowId: q.windowId ?? undefined } ); + } + } ); + + // Every fetch/XHR inside a window's page — an iframe window's, or + // an admin page embedded in a native window (the Posts app's block + // editor) — relayed by the shell from the chromeless bridge. + hook( HOOKS.IFRAME_NETWORK_COMPLETED, ( raw ) => { + const p = raw as { + status?: number; + duration?: number; + failed?: boolean; + aborted?: boolean; + method?: string; + url?: string; + windowId?: string; + }; + // The page cancelled it (search-as-you-type dropping its last + // query): not a request that failed, and not one that finished. + if ( p.aborted ) { + return; + } + const status = p.failed && ! p.status ? 0 : Number( p.status ) || 0; + health.recordRequest( now(), status ); + if ( typeof p.duration === 'number' ) { + latency( p.duration ); + } + beat( 'requests', { error: status === 0 || status >= 400, detail: statusDetail( status ) } ); + if ( status === 0 || status >= 400 ) { + incidents.add( { source: 'requests', detail: statusDetail( status ), status: status || undefined, method: p.method, url: p.url, windowId: p.windowId } ); + } + } ); + + /* --------------------------------------------------------------- + * assets (+ raw shell fetches) via resource timing + * ------------------------------------------------------------- */ + + /** Whether a window page runs the chromeless bridge's network reporter. */ + const bridged = ( win: Window ): boolean => { + try { + return typeof ( win as unknown as { __wpdInstrument?: unknown } ).__wpdInstrument === 'object'; + } catch { + return true; + } + }; + + const observeResources = ( win: Window, isShell: boolean, into: Array< () => void > = disposers ): void => { + const PO = ( win as unknown as { PerformanceObserver?: typeof PerformanceObserver } ).PerformanceObserver; + if ( typeof PO !== 'function' ) { + return; + } + let observer: PerformanceObserver; + try { + observer = new PO( ( list ) => { + for ( const entry of list.getEntries() as PerformanceResourceTiming[] ) { + const status = ( entry as { responseStatus?: number } ).responseStatus ?? 0; + const failed = status >= 400; + if ( FETCH_INITIATORS.has( entry.initiatorType ) ) { + // A window page with the chromeless bridge reports + // its own fetch/XHR (`os-iframe-network`). One + // without it — a 404, a fatal error, a screen that + // died before `admin_footer` — reports nothing, so + // resource timing is its only witness. + if ( ! isShell && bridged( win ) ) { + continue; + } + latency( entry.duration ); + if ( isShell && consume( entry.name ) ) { + continue; + } + health.recordRequest( now(), status || 200 ); + beat( 'requests', { error: failed, detail: statusDetail( status ) } ); + if ( failed ) { + incidents.add( { source: 'requests', detail: statusDetail( status ), status, url: entry.name } ); + } + } else { + beat( 'assets', { error: failed, detail: statusDetail( status ) } ); + if ( failed ) { + incidents.add( { source: 'assets', detail: statusDetail( status ), status, url: entry.name } ); + } + } + } + } ); + observer.observe( { type: 'resource', buffered: false } ); + } catch { + return; + } + into.push( () => observer.disconnect() ); + }; + + /* --------------------------------------------------------------- + * input + * ------------------------------------------------------------- */ + + const observeInput = ( doc: Document, into: Array< () => void > = disposers ): void => { + const capture: AddEventListenerOptions = { passive: true, capture: true }; + on( doc, 'pointerdown', () => beat( 'input', { amplitude: 0.16 } ), capture, into ); + on( doc, 'keydown', () => { + if ( gates.input( 'key' ) ) { + beat( 'input' ); + } + }, capture, into ); + on( doc, 'wheel', () => { + if ( gates.wheel( 'wheel' ) ) { + beat( 'input', { amplitude: 0.07 } ); + } + }, capture, into ); + }; + + observeResources( window, true ); + observeInput( document ); + + // Same-origin window iframes: resource timing + input. An iframe's + // `contentWindow` keeps its identity across navigations but its + // document does not, so documents are what we remember. Cross-origin + // frames throw on access and are skipped (their network traffic + // still arrives via the chromeless bridge). + // + // Each document's taps are released on their own — when it unloads + // (`pagehide`: a navigation inside the window, a reload) or when its + // frame leaves the shell (a closed window) — not with the hub. The + // hub lives as long as the wallpaper, and taps held until then would + // keep every page the user ever opened in memory. + type Release = () => void; + const seenDocs = new WeakSet< Document >(); + const frameTaps = new Map< Document, Release >(); + let wired = true; + const tapFrame = ( win: Window, doc: Document ): void => { + const own: Array< () => void > = []; + const release = (): void => { + if ( ! frameTaps.delete( doc ) ) { + return; + } + for ( const dispose of own.splice( 0 ) ) { + try { + dispose(); + } catch { + /* a frame torn down mid-release */ + } + } + }; + frameTaps.set( doc, release ); + observeResources( win, false, own ); + observeInput( doc, own ); + on( win, 'pagehide', release, { passive: true, once: true }, own ); + }; + const scanFrames = (): void => { + if ( ! wired ) { + return; + } + const live = new Set< Document >(); + for ( const frame of Array.from( document.querySelectorAll( 'iframe' ) ) ) { + const win = frame.contentWindow; + if ( ! win ) { + continue; + } + try { + const doc = win.document; + if ( ! doc || doc.URL === 'about:blank' ) { + continue; + } + live.add( doc ); + if ( seenDocs.has( doc ) ) { + continue; + } + seenDocs.add( doc ); + tapFrame( win, doc ); + } catch { + /* cross-origin */ + } + } + // A frame removed without unloading its page first. + for ( const [ doc, release ] of Array.from( frameTaps ) ) { + if ( ! live.has( doc ) ) { + release(); + } + } + }; + scanFrames(); + const frameTimer = window.setInterval( scanFrames, 1500 ); + let rescan = 0; + disposers.push( () => { + wired = false; + window.clearInterval( frameTimer ); + window.clearTimeout( rescan ); + for ( const release of Array.from( frameTaps.values() ) ) { + release(); + } + } ); + + /* --------------------------------------------------------------- + * pages, windows + * ------------------------------------------------------------- */ + + // Page loads. `os.window.content-loaded` only fires for loads the + // shell starts; a navigation INSIDE a window (a link, a form POST, + // a redirect) only shows up as the iframe's `load` — which does not + // bubble, but does reach a capturing document listener. The two + // overlap for shell-started loads, so a load within PAGE_DEDUPE_MS + // of the other counts once. + let lastPage = -Infinity; + const pageLoaded = ( status = 0, duration: number | null = null, url?: string ): void => { + const t = performance.now(); + if ( status ) { + health.recordRequest( now(), status ); + } + if ( duration !== null ) { + latency( duration ); + } + if ( t - lastPage < PAGE_DEDUPE_MS ) { + return; + } + lastPage = t; + beat( 'pages', { error: status >= 400, detail: statusDetail( status ) } ); + if ( status >= 400 ) { + incidents.add( { source: 'pages', detail: statusDetail( status ), status, url } ); + } + // A window just navigated — its new document is ready to tap. + window.clearTimeout( rescan ); + rescan = window.setTimeout( scanFrames, 50 ); + }; + hook( HOOKS.WINDOW_CONTENT_LOADED, () => pageLoaded() ); + on( document, 'load', ( e: Event ) => { + if ( ! ( e.target instanceof HTMLIFrameElement ) ) { + return; + } + // Same-origin frames tell us how the page itself went; + // cross-origin ones throw and still beat, status unknown. + let status = 0; + let duration: number | null = null; + let url: string | undefined; + try { + const nav = e.target.contentWindow?.performance.getEntriesByType( 'navigation' )[ 0 ] as + | ( PerformanceNavigationTiming & { responseStatus?: number } ) + | undefined; + status = nav?.responseStatus ?? 0; + duration = nav ? nav.responseEnd - nav.startTime : null; + url = nav?.name; + } catch { + /* cross-origin */ + } + pageLoaded( status, duration, url ); + }, { passive: true, capture: true } ); + + for ( const name of WINDOW_HOOKS ) { + hook( name, () => beat( 'windows' ) ); + } + hook( HOOKS.WINDOW_BOUNDS_CHANGED, () => { + if ( gates.bounds( 'bounds' ) ) { + beat( 'windows', { amplitude: 0.12 } ); + } + } ); + + /* --------------------------------------------------------------- + * messages, notices, uploads + * ------------------------------------------------------------- */ + + on( window, 'message', ( e: MessageEvent ) => { + // A window's network report already beats as a request. + if ( ( e.data as { type?: unknown } | null )?.type === 'os-iframe-network' ) { + return; + } + if ( gates.messages( 'message' ) ) { + beat( 'messages' ); + } + } ); + hook( HOOKS.BROADCAST, () => beat( 'messages', { amplitude: 0.25 } ) ); + hook( HOOKS.CONNECTION_MESSAGE, () => { + if ( gates.messages( 'connection' ) ) { + beat( 'messages' ); + } + } ); + + channel( 'os/toast-shown', () => beat( 'notices' ) ); + channel( 'os/notification-shown', () => beat( 'notices' ) ); + hook( HOOKS.ICON_BADGE_CHANGED, () => beat( 'notices', { amplitude: 0.3 } ) ); + + hook( HOOKS.FILE_DROP_UPLOAD_STARTED, () => beat( 'uploads', { amplitude: 0.5 } ) ); + hook( HOOKS.FILE_DROP_AFTER_UPLOAD, () => { + health.recordRequest( now(), 200 ); + beat( 'uploads' ); + } ); + hook( HOOKS.FILE_DROP_UPLOAD_FAILED, () => { + health.recordRequest( now(), 500 ); + beat( 'uploads', { error: true, detail: __( 'upload failed' ) } ); + incidents.add( { source: 'uploads', detail: __( 'upload failed' ) } ); + } ); + + /* --------------------------------------------------------------- + * errors, connectivity + * ------------------------------------------------------------- */ + + const jsError = ( message: unknown, where?: { file?: unknown; line?: unknown; windowId?: unknown } ): void => { + const detail = shortDetail( message ) || __( 'JavaScript error' ); + health.recordJsError( now() ); + beat( 'errors', { detail } ); + const file = typeof where?.file === 'string' && where.file ? where.file : ''; + incidents.add( { + source: 'errors', + // The full message, not the label's 48 characters. + detail: String( message ?? '' ).trim() || detail, + where: file ? `${ file }${ typeof where?.line === 'number' && where.line ? `:${ where.line }` : '' }` : undefined, + windowId: typeof where?.windowId === 'string' ? where.windowId : undefined, + } ); + }; + const messageOf = ( value: unknown ): unknown => + ( value as { message?: unknown } | null )?.message ?? value; + // Capture phase: resource load failures (a broken ) fire on the + // element and only reach window while capturing. + on( window, 'error', ( e: Event ) => { + if ( e instanceof ErrorEvent ) { + jsError( e.message, { file: e.filename, line: e.lineno } ); + } else { + const target = e.target as ( Element & { src?: string; href?: string } ) | null; + const tag = target?.tagName?.toLowerCase() ?? 'resource'; + /* translators: %s: HTML tag name of the element that failed to load, e.g. "img". */ + const detail = sprintf( __( '%s failed to load' ), tag ); + beat( 'errors', { amplitude: 0.5, detail } ); + incidents.add( { source: 'errors', detail, url: target?.src || target?.href || undefined } ); + } + }, { passive: true, capture: true } ); + on( window, 'unhandledrejection', ( e: Event ) => + jsError( messageOf( ( e as PromiseRejectionEvent ).reason ) ), + ); + hook( HOOKS.IFRAME_ERROR, ( raw ) => { + const p = raw as { message?: string; filename?: string | null; lineno?: number | null; windowId?: string } | undefined; + jsError( p?.message, { file: p?.filename, line: p?.lineno, windowId: p?.windowId } ); + } ); + hook( HOOKS.SHELL_ERROR, ( raw ) => { + const p = raw as { scope?: string; error?: unknown } | undefined; + jsError( p?.error ? messageOf( p.error ) : p?.scope ); + } ); + hook( HOOKS.COMMAND_ERROR, ( raw ) => + jsError( messageOf( ( raw as { error?: unknown } | undefined )?.error ) ), + ); + hook( HOOKS.AUTH_LOST, () => { + health.setAuthLost( true ); + beat( 'errors', { detail: __( 'session expired' ) } ); + incidents.add( { source: 'errors', detail: __( 'session expired' ) } ); + } ); + hook( HOOKS.AUTH_RESTORED, () => health.setAuthLost( false ) ); + + const syncOnline = (): void => health.setOffline( navigator.onLine === false ); + syncOnline(); + on( window, 'online', syncOnline ); + on( window, 'offline', () => { + syncOnline(); + beat( 'errors', { detail: __( 'offline' ) } ); + incidents.add( { source: 'errors', detail: __( 'offline' ) } ); + } ); + + /* --------------------------------------------------------------- + * plugins + * ------------------------------------------------------------- */ + + channel( 'pulse/beat', ( raw ) => { + const p = ( raw ?? {} ) as { source?: unknown; error?: unknown; amplitude?: unknown; detail?: unknown }; + beat( typeof p.source === 'string' && p.source ? p.source : 'custom', { + error: p.error === true, + detail: shortDetail( p.detail ), + amplitude: + typeof p.amplitude === 'number' && Number.isFinite( p.amplitude ) + ? Math.min( 2, Math.max( 0, p.amplitude ) ) + : undefined, + } ); + } ); + + /* --------------------------------------------------------------- + * heartbeat + the server feed + * ------------------------------------------------------------- */ + + const $ = ( window as unknown as { jQuery?: JQueryish } ).jQuery; + const wpHeartbeat = ( window.wp as unknown as { heartbeat?: WpHeartbeat } | undefined )?.heartbeat; + if ( $ ) { + const doc = $( document ); + doc.on( 'heartbeat-send.osPulse', () => { + const ajaxurl = ( window as unknown as { ajaxurl?: string } ).ajaxurl; + if ( ajaxurl ) { + remember( ajaxurl ); + } + } ); + doc.on( 'heartbeat-tick.osPulse', () => { + health.recordRequest( now(), 200 ); + health.setHeartbeatLost( false ); + beat( 'heartbeat' ); + } ); + doc.on( 'heartbeat-error.osPulse', ( ...args: unknown[] ) => { + const jqXHR = args[ 1 ] as { status?: number } | undefined; + const status = Number( jqXHR?.status ) || 0; + health.recordRequest( now(), status ); + // textStatus: "timeout", "error", "abort", "parsererror". + const detail = statusDetail( status, shortDetail( args[ 2 ] ) || 'error' ); + beat( 'heartbeat', { error: true, detail } ); + incidents.add( { + source: 'heartbeat', + detail, + status: status || undefined, + method: 'POST', + url: ( window as unknown as { ajaxurl?: string } ).ajaxurl, + } ); + } ); + doc.on( 'heartbeat-connection-lost.osPulse', () => { + health.setHeartbeatLost( true ); + incidents.add( { source: 'heartbeat', detail: __( 'connection lost' ) } ); + } ); + doc.on( 'heartbeat-connection-restored.osPulse', () => health.setHeartbeatLost( false ) ); + disposers.push( () => doc.off( '.osPulse' ) ); + } + + // Site-wide events. The cursor starts at 0, which the server answers + // with its current sequence only — mounting does not replay history. + let seq = 0; + let lastResponse = performance.now(); + disposers.push( heartbeat.contribute( HEARTBEAT_FIELD, () => ( { since: seq } ) ) ); + disposers.push( + heartbeat.subscribe< ServerPayload >( HEARTBEAT_FIELD, ( payload ) => { + const arrived = performance.now(); + // Replay the last tick's worth of server events at their real + // spacing, one tick late — a steady rhythm instead of a burst + // every 15–60 seconds. + const span = Math.min( 60000, Math.max( 5000, arrived - lastResponse ) ); + lastResponse = arrived; + if ( seq > 0 && Array.isArray( payload?.events ) && typeof payload.now === 'number' ) { + const start = payload.now - span; + for ( const ev of payload.events ) { + const offset = Math.min( span, Math.max( 0, ev.ts - start ) ); + const detail = ev.error ? statusDetail( ev.status, __( 'failed' ) ) : undefined; + beat( String( ev.kind ), { error: ev.error === true, delay: offset, detail } ); + if ( ev.error === true ) { + incidents.add( { source: String( ev.kind ), detail: detail ?? '', status: ev.status || undefined, server: true, at: ev.ts } ); + } + if ( ev.kind === 'visitors' || ev.kind === 'bots' ) { + health.recordVisit( now() ); + } + } + } + if ( typeof payload?.seq === 'number' ) { + seq = payload.seq; + } + if ( payload?.health ) { + health.setServer( now(), payload.health ); + } + } ), + ); + + // Server events arrive one tick late, so a faster tick is a + // livelier trace. Raise a slow Heartbeat to 15 s while mounted and + // put it back afterwards. + if ( wpHeartbeat && typeof wpHeartbeat.interval === 'function' ) { + const previous = wpHeartbeat.interval(); + if ( previous > 15 ) { + wpHeartbeat.interval( 15 ); + disposers.push( () => { + if ( wpHeartbeat.interval() === 15 ) { + wpHeartbeat.interval( previous ); + } + } ); + } + wpHeartbeat.connectNow?.(); + } + + return (): void => { + for ( const dispose of disposers.splice( 0 ) ) { + try { + dispose(); + } catch { + /* one failed teardown must not strand the rest */ + } + } + }; +} diff --git a/src/plugins/pulse-monitor/sources.ts b/src/plugins/pulse-monitor/sources.ts new file mode 100644 index 00000000..23c4132e --- /dev/null +++ b/src/plugins/pulse-monitor/sources.ts @@ -0,0 +1,164 @@ +/** + * Pulse Monitor — signal sources. + * + * Every beat on the trace comes from one source. The catalog below is + * what the settings dialog lists under "Listening to", what the + * scheduler uses to pick a beat's shape and height, and the vocabulary + * the server feed (`includes/pulse-monitor.php`) speaks for the + * site-wide kinds. + */ + +import { __ } from '../../i18n'; + +/** + * Beat morphologies the rhythm engine can draw. + * + * - `qrs` a full P-QRS-T complex: a request, a visitor, a page. + * - `double` two R spikes ("lub-dub"): a WordPress Heartbeat tick. + * - `flutter` a short, half-height spike: a window event, a toast. + * - `blip` a tiny tick on the baseline: a click, an asset, a message. + * - `pvc` a wide inverted ectopic beat: anything that failed. + */ +export type BeatShape = 'qrs' | 'double' | 'flutter' | 'blip' | 'pvc'; + +export type SourceGroup = 'client' | 'server'; + +export interface SourceDef { + id: string; + group: SourceGroup; + label: string; + shape: BeatShape; + /** Height relative to a full R wave. */ + amplitude: number; +} + +/** Every known source id. */ +export const SOURCE_IDS = [ + 'requests', + 'pages', + 'heartbeat', + 'assets', + 'windows', + 'input', + 'messages', + 'notices', + 'uploads', + 'errors', + 'visitors', + 'bots', + 'comments', + 'publish', + 'orders', + 'logins', + 'api', + 'cron', + 'team', +] as const; + +export type SourceId = ( typeof SOURCE_IDS )[ number ]; + +/** Shape + height for a source, without the translated label. */ +const SHAPES: Record< string, { shape: BeatShape; amplitude: number } > = { + requests: { shape: 'qrs', amplitude: 0.85 }, + pages: { shape: 'qrs', amplitude: 1 }, + heartbeat: { shape: 'double', amplitude: 0.9 }, + assets: { shape: 'blip', amplitude: 0.14 }, + windows: { shape: 'flutter', amplitude: 0.42 }, + input: { shape: 'blip', amplitude: 0.1 }, + messages: { shape: 'blip', amplitude: 0.12 }, + notices: { shape: 'flutter', amplitude: 0.55 }, + uploads: { shape: 'qrs', amplitude: 0.9 }, + errors: { shape: 'pvc', amplitude: 0.9 }, + visitors: { shape: 'qrs', amplitude: 1 }, + bots: { shape: 'flutter', amplitude: 0.5 }, + comments: { shape: 'qrs', amplitude: 1.25 }, + publish: { shape: 'qrs', amplitude: 1.3 }, + orders: { shape: 'qrs', amplitude: 1.3 }, + logins: { shape: 'qrs', amplitude: 1.1 }, + api: { shape: 'flutter', amplitude: 0.6 }, + cron: { shape: 'flutter', amplitude: 0.5 }, + team: { shape: 'qrs', amplitude: 0.7 }, +}; + +/** Server-fed sources — everything else is observed in the shell. */ +const SERVER_SOURCES: ReadonlySet< string > = new Set( [ + 'visitors', + 'bots', + 'comments', + 'publish', + 'orders', + 'logins', + 'api', + 'cron', + 'team', +] ); + +/** Settings-dialog labels. A function so `__()` runs after i18n boots. */ +function sourceLabels(): Record< SourceId, string > { + return { + requests: __( 'API requests (fetch, XHR, REST, admin-ajax)' ), + pages: __( 'Page loads in windows' ), + heartbeat: __( 'WordPress Heartbeat' ), + assets: __( 'Scripts, styles, images and fonts' ), + windows: __( 'Window and desktop events' ), + input: __( 'Clicks, typing and scrolling' ), + messages: __( 'Messages between windows' ), + notices: __( 'Notifications and toasts' ), + uploads: __( 'Uploads' ), + errors: __( 'Errors (failed requests, JavaScript errors)' ), + visitors: __( 'Visitors' ), + bots: __( 'Bots and crawlers' ), + comments: __( 'Comments' ), + publish: __( 'Published posts' ), + orders: __( 'Orders' ), + logins: __( 'Logins' ), + api: __( 'API and app requests' ), + cron: __( 'Cron runs' ), + team: __( 'Other people working in the admin' ), + }; +} + +/** + * The word printed on a spike — short, lowercase, what a person would + * say happened. Unknown ids (plugin kinds) print as themselves. + */ +export function spikeLabel( source: string ): string { + const labels: Record< string, string > = { + requests: __( 'request' ), + pages: __( 'page load' ), + heartbeat: __( 'heartbeat' ), + windows: __( 'window' ), + notices: __( 'notice' ), + uploads: __( 'upload' ), + errors: __( 'error' ), + visitors: __( 'visit' ), + bots: __( 'bot' ), + comments: __( 'comment' ), + publish: __( 'published' ), + orders: __( 'order' ), + logins: __( 'login' ), + api: __( 'API' ), + cron: __( 'cron' ), + team: __( 'teammate' ), + }; + return labels[ source ] ?? source.replace( /[-_]+/g, ' ' ); +} + +/** The catalog, in the order the settings dialog lists it. */ +export function sourceCatalog(): SourceDef[] { + const labels = sourceLabels(); + return SOURCE_IDS.map( ( id ) => ( { + id, + group: SERVER_SOURCES.has( id ) ? 'server' : 'client', + label: labels[ id ], + ...SHAPES[ id ], + } ) ); +} + +/** + * Shape + height for any source id. Unknown ids (a plugin's custom + * server kind, a `pulse/beat` publish) get a standard beat. + */ +export function shapeFor( source: string ): { shape: BeatShape; amplitude: number } { + return SHAPES[ source ] ?? { shape: 'qrs', amplitude: 0.9 }; +} diff --git a/src/plugins/pulse-monitor/wallpaper.ts b/src/plugins/pulse-monitor/wallpaper.ts new file mode 100644 index 00000000..48422fb9 --- /dev/null +++ b/src/plugins/pulse-monitor/wallpaper.ts @@ -0,0 +1,249 @@ +/** + * Pulse Monitor — the wallpaper face (`wp-pulse`). + * + * The whole desk as a heart monitor: {@link mountMonitor} in its + * `wallpaper` variant, plus the shell hooks a canvas wallpaper owes + * (visibility, live settings) and a synthetic OS Settings tile preview. + * + * Two layouts (`settings.layout`): + * + * - `full`: the trace runs across the whole wallpaper, under the desk. + * - `band`: the shell reserves a strip along the top + * (`reserveWorkArea()`), so no window, icon or widget can be put + * there — maximize, snapping, dragging and resizing all stop at + * it. The trace and its numbers mount on a layer over that strip; + * with nothing able to cover it, the numbers become a button that + * opens the Issues window. The rest of the wallpaper is the plain + * background colour. Never on a phone: in the shell's mobile mode + * apps are full-screen, so the band falls back to `full` and comes + * back when the mode does. + */ + +import { __ } from '../../i18n'; +import { addAction, removeAction, HOOKS } from '../../hooks'; +import { reservedRectOf, reserveWorkArea, subscribeWorkArea } from '../../work-area'; +import type { + WallpaperContext, + WallpaperDef, + WallpaperPreviewContext, + WallpaperTeardown, +} from '../../wallpapers/types'; +import { renderPulseConfig } from './config'; +import { mountMonitor, type Monitor } from './monitor'; +import { getPixi } from './pixi-types'; +import { Rhythm } from './rhythm'; +import { mountPulseScene } from './scene'; +import { PULSE_DEFAULTS, sanitizePulseSettings, type PulseSettings } from './settings'; + +/** Stable id — persisted as the user's selected wallpaper, and the key of its settings bag. */ +export const WALLPAPER_ID = 'wp-pulse'; + +const NAMESPACE = 'desktop-mode/pulse'; + +/** Work-area reservation id for the band layout. */ +export const BAND_RESERVATION = 'desktop-mode/pulse'; + +const STYLE_ID = 'os-pulse-band-style'; + +function ensureStyle(): void { + if ( document.getElementById( STYLE_ID ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = STYLE_ID; + style.textContent = ` +/* Full layout: the monitor fills the wallpaper layer. */ +.os-pulse-stage { position: absolute; inset: 0; } +/* The reserved band's layer: above the desk area and anything stacked in + it (nothing may be placed in the band, so it covers nothing), under + Mío and the docks; a fullscreen window still covers it. Click-through + except for the numbers. */ +.os-pulse-band { + position: absolute; + z-index: calc( var( --os-z-mio, 190 ) - 10 ); + overflow: hidden; + pointer-events: none; + box-shadow: inset 0 -1px 0 rgb(255 255 255 / 0.08); +} +`; + document.head.appendChild( style ); +} + +const noop = (): void => { + /* noop */ +}; + +async function mountWallpaper( container: HTMLElement, ctx: WallpaperContext ): Promise< WallpaperTeardown > { + const pixi = getPixi(); + if ( ! pixi ) { + return noop; + } + ensureStyle(); + const shell = container.closest< HTMLElement >( '#os-shell' ); + const priorBackground = container.style.background; + let settings: PulseSettings = sanitizePulseSettings( ctx.settings ); + let monitor: Monitor | null = null; + let stage: HTMLElement | null = null; + let releaseBand = noop; + let offWorkArea = noop; + let animating = true; + let generation = 0; + let destroyed = false; + + // Phones: apps open full-screen, so a reserved strip only takes room + // from them. + const mobile = (): boolean => { + const mode = ( window.wp as unknown as { os?: { mode?: { isMobile?: () => boolean } } } | undefined )?.os?.mode; + return mode?.isMobile?.() ?? document.documentElement.dataset.osMode === 'mobile'; + }; + const banded = (): boolean => settings.layout === 'band' && shell !== null && ! mobile(); + let builtBanded = false; + + const placeBand = (): void => { + const rect = reservedRectOf( BAND_RESERVATION ); + if ( ! stage || ! shell || ! rect ) { + return; + } + const origin = shell.getBoundingClientRect(); + stage.style.left = `${ rect.x - origin.left }px`; + stage.style.top = `${ rect.y - origin.top }px`; + stage.style.width = `${ rect.width }px`; + stage.style.height = `${ rect.height }px`; + }; + + const teardownStage = (): void => { + monitor?.destroy(); + monitor = null; + offWorkArea(); + offWorkArea = noop; + releaseBand(); + releaseBand = noop; + stage?.remove(); + stage = null; + container.style.background = priorBackground; + }; + + const build = async (): Promise< void > => { + teardownStage(); + const mine = ++generation; + const band = banded(); + builtBanded = band; + const host = document.createElement( 'div' ); + stage = host; + if ( band && shell ) { + host.className = 'os-pulse-band'; + // Below the band the wallpaper is plain background. + container.style.background = settings.background; + releaseBand = reserveWorkArea( BAND_RESERVATION, { edge: 'top', size: settings.bandHeight } ); + shell.appendChild( host ); + placeBand(); + offWorkArea = subscribeWorkArea( placeBand ); + } else { + host.className = 'os-pulse-stage'; + container.appendChild( host ); + } + const next = await mountMonitor( { + container: host, + pixi, + settings, + prefersReducedMotion: ctx.prefersReducedMotion, + variant: band ? 'band' : 'wallpaper', + } ); + // A layout switch (or the unmount) raced the mount. + if ( destroyed || mine !== generation ) { + next.destroy(); + return; + } + monitor = next; + monitor.setAnimating( animating ); + }; + + await build(); + + // Pause while the document is hidden or the shell suspends us. + addAction( HOOKS.WALLPAPER_VISIBILITY, `${ NAMESPACE }/visibility`, ( ...args: unknown[] ) => { + const detail = args[ 0 ] as { id?: string; state?: 'visible' | 'hidden' } | undefined; + if ( detail?.id === WALLPAPER_ID ) { + animating = detail.state === 'visible'; + monitor?.setAnimating( animating ); + } + } ); + // Entering or leaving mobile mode switches the band off or on. + addAction( HOOKS.MODE_CHANGED, `${ NAMESPACE }/mode`, () => { + if ( banded() !== builtBanded ) { + void build(); + } + } ); + addAction( HOOKS.WALLPAPER_SETTINGS_CHANGED, `${ NAMESPACE }/settings-changed`, ( ...args: unknown[] ) => { + const detail = args[ 0 ] as { id?: string; settings?: Record< string, unknown > } | undefined; + if ( detail?.id !== WALLPAPER_ID ) { + return; + } + const previous = settings; + settings = sanitizePulseSettings( detail.settings ); + if ( settings.layout !== previous.layout || banded() !== builtBanded ) { + void build(); + return; + } + if ( banded() ) { + if ( settings.bandHeight !== previous.bandHeight ) { + // Same id: the band is resized in place. + releaseBand = reserveWorkArea( BAND_RESERVATION, { edge: 'top', size: settings.bandHeight } ); + placeBand(); + } + container.style.background = settings.background; + } + monitor?.applySettings( settings ); + } ); + + return (): void => { + destroyed = true; + removeAction( HOOKS.WALLPAPER_VISIBILITY, `${ NAMESPACE }/visibility` ); + removeAction( HOOKS.MODE_CHANGED, `${ NAMESPACE }/mode` ); + removeAction( HOOKS.WALLPAPER_SETTINGS_CHANGED, `${ NAMESPACE }/settings-changed` ); + teardownStage(); + }; +} + +/** + * Tile preview: the real scene, fed by a synthetic site — a resting + * rhythm plus a scatter of requests, visitors and clicks — because a + * swatch should not tap the shell's real traffic. + */ +async function mountPreview( container: HTMLElement, ctx: WallpaperPreviewContext ): Promise< WallpaperTeardown > { + const pixi = getPixi(); + if ( ! pixi ) { + return noop; + } + const settings = { ...sanitizePulseSettings( ctx.settings ), echoes: 3, speed: 90, lineWidth: 1.5 }; + const rhythm = new Rhythm( { restingBpm: 72 } ); + const scene = await mountPulseScene( { + container, + pixi, + settings, + prefersReducedMotion: ctx.prefersReducedMotion, + rhythm, + variant: 'preview', + } ); + const sources = [ 'requests', 'visitors', 'input', 'assets', 'heartbeat' ]; + const timer = window.setInterval( () => { + if ( Math.random() < 0.5 ) { + rhythm.push( { source: sources[ Math.floor( Math.random() * sources.length ) ] } ); + } + }, 700 ); + return (): void => { + window.clearInterval( timer ); + scene.destroy(); + }; +} + +export const wallpaperDef: WallpaperDef = { + id: WALLPAPER_ID, + label: __( 'Pulse Monitor' ), + type: 'canvas', + preview: PULSE_DEFAULTS.background, + needs: [ 'pixijs' ], + renderPreview: mountPreview, + mount: mountWallpaper, + renderConfig: renderPulseConfig, +}; diff --git a/src/plugins/pulse-monitor/widget.ts b/src/plugins/pulse-monitor/widget.ts new file mode 100644 index 00000000..292cb0a8 --- /dev/null +++ b/src/plugins/pulse-monitor/widget.ts @@ -0,0 +1,176 @@ +/** + * Pulse Monitor — the widget face (`desktop-mode/pulse`). + * + * The same monitor as the wallpaper — same engine, same signal hub, + * same diagnosis — drawn into a widget card: a smaller trace with + * taller beats and a slower sweep, the numbers along its bottom edge, + * and a gear that opens the settings form. Its settings are its own, + * kept in the widget's store, so the widget and the wallpaper can + * look and listen differently. Wallpaper and widget can run together; + * the hub wires the signal taps once for both. + */ + +import { __ } from '../../i18n'; +import '../../ui/components/os-modal/os-modal'; +import '../../ui/components/os-button/os-button'; +import { osIconSvg } from '../../ui/icons'; +import type { WallpaperSettings } from '../../wallpapers/settings-store'; +import type { WidgetContext, WidgetTeardown } from '../../widgets/types'; +import { renderPulseConfig } from './config'; +import { mountMonitor, type Monitor } from './monitor'; +import { getPixi } from './pixi-types'; +import { sanitizePulseSettings } from './settings'; + +export const WIDGET_ID = 'desktop-mode/pulse'; + +const STYLE_ID = 'os-pulse-widget-style'; + +function ensureStyle(): void { + if ( document.getElementById( STYLE_ID ) ) { + return; + } + const style = document.createElement( 'style' ); + style.id = STYLE_ID; + style.textContent = ` +.os-widget-pulse { position: relative; height: 100%; min-height: 120px; } +.os-widget-pulse__screen { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: 10px; +} +/* Placement and reveal only; the look is 's. */ +.os-widget-pulse__gear { + position: absolute; + inset-block-start: 6px; + inset-inline-end: 6px; + z-index: 2; + --os-ui-button-padding: 4px; + opacity: 0; + transition: opacity 120ms ease; +} +.os-widget-pulse__gear svg { display: block; } +.os-widget-pulse:hover .os-widget-pulse__gear, +.os-widget-pulse__gear:focus-within { opacity: 1; } +.os-widget-pulse__form { display: flex; flex-direction: column; gap: 12px; } +@media (prefers-reduced-motion: reduce) { + .os-widget-pulse__gear { transition: none; } +} +`; + document.head.appendChild( style ); +} + +/** Load PixiJS through the shell's module registry (widgets have no `needs`). */ +async function loadPixi(): Promise< void > { + const load = ( window.wp as unknown as { os?: { loadModules?: ( ids: string[] ) => Promise< void > } } | undefined )?.os + ?.loadModules; + if ( typeof load !== 'function' ) { + throw new Error( 'wp.os.loadModules is not available.' ); + } + await load( [ 'pixijs' ] ); +} + +/** The widget's own settings, in its per-widget store (`ctx.storage`). */ +const SETTINGS_KEY = 'settings'; + +/** + * The gear's dialog: the settings form in an ``. + * + * @return Closes the dialog (a no-op once it is closed). + */ +function openSettings( current: () => WallpaperSettings, write: ( partial: WallpaperSettings ) => void ): () => void { + const modal = document.createElement( 'os-modal' ); + modal.setAttribute( 'size', 'sm' ); + modal.setAttribute( 'title', __( 'Pulse Monitor widget settings' ) ); + const body = document.createElement( 'div' ); + body.className = 'os-widget-pulse__form'; + modal.appendChild( body ); + const done = document.createElement( 'os-button' ); + done.setAttribute( 'slot', 'footer' ); + done.setAttribute( 'variant', 'primary' ); + done.textContent = __( 'Done' ); + modal.appendChild( done ); + + const teardown = renderPulseConfig( body, { settings: current(), setSettings: write, face: 'widget' } ); + let open = true; + const close = (): void => { + if ( ! open ) { + return; + } + open = false; + teardown(); + modal.remove(); + }; + done.addEventListener( 'click', close ); + modal.addEventListener( 'os-modal-cancel', close ); + document.body.appendChild( modal ); + modal.setAttribute( 'open', '' ); + return close; +} + +export async function mountWidget( container: HTMLElement, ctx: WidgetContext ): Promise< WidgetTeardown > { + ensureStyle(); + container.classList.add( 'os-widget-pulse' ); + try { + await loadPixi(); + } catch ( err ) { + container.textContent = ( err as Error ).message; + return (): void => undefined; + } + const pixi = getPixi(); + if ( ! pixi ) { + return (): void => undefined; + } + + const screen = document.createElement( 'div' ); + screen.className = 'os-widget-pulse__screen'; + container.appendChild( screen ); + + const gear = document.createElement( 'os-button' ); + gear.setAttribute( 'variant', 'ghost' ); + gear.className = 'os-widget-pulse__gear'; + // Static SVG markup from the icon set — no user content involved. + gear.innerHTML = osIconSvg( 'settings', { size: 16 } ); + gear.setAttribute( 'aria-label', __( 'Pulse Monitor settings' ) ); + let stored: WallpaperSettings = ctx.storage.get< WallpaperSettings >( SETTINGS_KEY ) ?? {}; + let monitor: Monitor | null = null; + const write = ( partial: WallpaperSettings ): void => { + stored = { ...stored, ...partial }; + ctx.storage.set( SETTINGS_KEY, stored ); + monitor?.applySettings( sanitizePulseSettings( stored ) ); + }; + // The open dialog, so removing the widget can take it down too. + let closeSettings: ( () => void ) | null = null; + const onGear = (): void => { + closeSettings?.(); + closeSettings = openSettings( () => stored, write ); + }; + gear.addEventListener( 'click', onGear ); + container.appendChild( gear ); + + const mounted: Monitor = await mountMonitor( { + container: screen, + pixi, + settings: sanitizePulseSettings( stored ), + prefersReducedMotion: window.matchMedia?.( '(prefers-reduced-motion: reduce)' ).matches ?? false, + variant: 'widget', + } ); + + monitor = mounted; + // Widgets get no visibility hook; follow the document like the + // wallpaper's own suspend does. + const onVisibility = (): void => mounted.setAnimating( document.visibilityState === 'visible' ); + document.addEventListener( 'visibilitychange', onVisibility ); + onVisibility(); + + return (): void => { + document.removeEventListener( 'visibilitychange', onVisibility ); + gear.removeEventListener( 'click', onGear ); + closeSettings?.(); + mounted.destroy(); + monitor = null; + screen.remove(); + gear.remove(); + container.classList.remove( 'os-widget-pulse' ); + }; +} diff --git a/src/window-manager/index.ts b/src/window-manager/index.ts index 07b953bd..13b94c53 100644 --- a/src/window-manager/index.ts +++ b/src/window-manager/index.ts @@ -84,7 +84,7 @@ import { cancelOverviewTimers, enterOverview, exitOverview } from './overview'; import { loadNativeWindowGeometry } from './native-window-geometry'; import { clampWindowPosition } from '../window/pointer'; import { focusIsFromUser } from '../window/focus-intent'; -import { subscribeWorkArea, workAreaRectOf, type WorkAreaRect } from '../work-area'; +import { getReservedInsets, placementRectOf, subscribeWorkArea, workAreaRectOf, type WorkAreaRect } from '../work-area'; /** Base z-index for desktop windows. */ const BASE_Z_INDEX = 100; @@ -572,13 +572,10 @@ export class WindowManager { const currentY = parseInt( w.element.style.top, 10 ) || 0; const width = w.element.offsetWidth || 0; - // Manual placement still uses the whole desktop. - const safe = clampWindowPosition( currentX, currentY, width, { - x: 0, - y: 0, - width: parent.clientWidth, - height: parent.clientHeight, - } ); + // Manual placement still uses the whole desktop, less + // any reserved band: one reserved after the window was + // placed pushes it out. + const safe = clampWindowPosition( currentX, currentY, width, placementRectOf( parent ) ); if ( currentX !== safe.x || currentY !== safe.y ) { w.element.classList.add( 'os-window--reflowing' ); @@ -1190,7 +1187,10 @@ export class WindowManager { // and height to the registered minima — a buggy filter cannot // ship a sub-minimum window. Position is NOT re-clamped: // plugins sometimes deliberately place windows partially - // off-screen for stylistic reasons. + // off-screen for stylistic reasons — except into a reserved + // band (`reserveWorkArea()`), which nothing may cover, pinned + // or not: a native window defaulting to 0,0 would otherwise + // open with its title bar under the band. const coalesce = ( v: unknown, fallback: number ): number => typeof v === 'number' && Number.isFinite( v ) ? v : fallback; const safeFiltered: ResolvedWindowGeometry = @@ -1203,8 +1203,16 @@ export class WindowManager { coalesce( safeFiltered.height, resolvedHeight ), minHeight, ); - const finalX = coalesce( safeFiltered.x, resolvedX ); - const finalY = coalesce( safeFiltered.y, resolvedY ); + const reserved = getReservedInsets(); + const placement = reserved.top > 0 || reserved.left > 0 ? placementRectOf( this._desktop ) : null; + let finalX = coalesce( safeFiltered.x, resolvedX ); + let finalY = coalesce( safeFiltered.y, resolvedY ); + if ( placement && reserved.left > 0 ) { + finalX = Math.max( finalX, placement.x ); + } + if ( placement && reserved.top > 0 ) { + finalY = Math.max( finalY, placement.y ); + } const finalState: WindowState | undefined = safeFiltered.state ?? resolvedState; diff --git a/src/window/iframe-reports.test.ts b/src/window/iframe-reports.test.ts index 414949dc..6eecad46 100644 --- a/src/window/iframe-reports.test.ts +++ b/src/window/iframe-reports.test.ts @@ -20,11 +20,21 @@ describe( 'relayIframeReport', () => { expect( relayIframeReport( 'w1', { type: 'os-iframe-network', url: '/a', status: 503, duration: 12, requestHeaders: { a: '1' } } ) ).toBe( true ); expect( seen ).toEqual( [ - { windowId: 'w1', method: 'GET', url: '', status: 0, duration: 0, failed: true }, - { windowId: 'w1', method: 'GET', url: '/a', status: 503, duration: 12, failed: false, requestHeaders: { a: '1' } }, + { windowId: 'w1', method: 'GET', url: '', status: 0, duration: 0, failed: true, aborted: false }, + { windowId: 'w1', method: 'GET', url: '/a', status: 503, duration: 12, failed: false, aborted: false, requestHeaders: { a: '1' } }, ] ); } ); + test( 'passes through a request the page cancelled itself', () => { + const seen: Array< { aborted?: boolean } > = []; + addAction( HOOKS.IFRAME_NETWORK_COMPLETED, 'test', ( p: unknown ) => seen.push( p as { aborted?: boolean } ) ); + + relayIframeReport( 'w1', { type: 'os-iframe-network', url: '/search?q=a', status: 0, failed: true, aborted: true } ); + relayIframeReport( 'w1', { type: 'os-iframe-network', url: '/search?q=b', status: 0, failed: true, aborted: 'yes' } ); + + expect( seen.map( ( p ) => p.aborted ) ).toEqual( [ true, false ] ); + } ); + test( 'normalises an error report', () => { const seen: unknown[] = []; addAction( HOOKS.IFRAME_ERROR, 'test', ( p: unknown ) => seen.push( p ) ); diff --git a/src/window/iframe-reports.ts b/src/window/iframe-reports.ts index f5541695..b5dc1067 100644 --- a/src/window/iframe-reports.ts +++ b/src/window/iframe-reports.ts @@ -56,7 +56,9 @@ export function relayIframeReport( windowId: string, data: unknown ): boolean { // call (success or failure) fires here. `status === 0` is a // network-level failure before a response arrived; `failed` is // pre-computed inside the page so subscribers don't re-derive the - // success / 4xx / 5xx / network boundary. + // success / 4xx / 5xx / network boundary. `aborted` marks a status-0 + // report the page caused itself by cancelling the request — not a + // network problem. if ( d.type === 'os-iframe-network' ) { const payload: Record< string, unknown > = { windowId, @@ -65,6 +67,7 @@ export function relayIframeReport( windowId: string, data: unknown ): boolean { status: typeof d.status === 'number' ? d.status : 0, duration: typeof d.duration === 'number' ? d.duration : 0, failed: !! d.failed, + aborted: d.aborted === true, }; // `requestHeaders` / `responseHeaders` only ride along when // devtools have asked the page to observe (via diff --git a/src/window/pointer.ts b/src/window/pointer.ts index 2c7ade92..11e565ce 100644 --- a/src/window/pointer.ts +++ b/src/window/pointer.ts @@ -13,7 +13,7 @@ import { doAction, HOOKS } from '../hooks'; import { isMobileStamped } from '../mode/stamp'; -import { workAreaRectOf, type WorkAreaRect } from '../work-area'; +import { placementRectOf, workAreaRectOf, type WorkAreaRect } from '../work-area'; import { createShakeDetector, dispatchShake } from './shake'; import { DRAG_THRESHOLD_SQUARED, EDGE_MARGIN, GRAB_MARGIN } from './constants'; import type { Window } from './index'; @@ -285,15 +285,11 @@ export function handleDragStart( win: Window, e: PointerEvent ): void { // Constrain to the desktop area keeping GRAB_MARGIN visible. // The whole area, not the work area: a window dragged under // the dock is where the user put it, and the title bar stays - // grabbable either way. + // grabbable either way. Reserved bands are the exception — no + // window goes there, dragged or not. const desktop = win.element.parentElement; if ( desktop ) { - const safe = clampWindowPosition( x, y, win.element.offsetWidth, { - x: 0, - y: 0, - width: desktop.clientWidth, - height: desktop.clientHeight, - } ); + const safe = clampWindowPosition( x, y, win.element.offsetWidth, placementRectOf( desktop ) ); x = safe.x; y = safe.y; } @@ -591,6 +587,7 @@ export function handleResizeStart( win: Window, e: PointerEvent ): void { win.config.minWidth, win.config.minHeight, snap, + win.element.parentElement ? placementRectOf( win.element.parentElement ) : undefined, ); win.element.style.left = `${ geom.x }px`; @@ -643,7 +640,8 @@ export function handleResizeStart( win: Window, e: PointerEvent ): void { * All resulting dimensions are clamped to the window's configured * minimums. When snap-to-grid is enabled, width/height (and the * resulting x/y when the top-left moves) are quantized to whole - * cells. + * cells. `bounds` (desktop-area-local) moves the top/left limit off + * the origin — a reserved band the window may not be resized into. */ export function computeResize( dir: ResizeDir, @@ -656,6 +654,7 @@ export function computeResize( minWidth: number, minHeight: number, snap: { enabled: boolean; cellWidth: number; cellHeight: number }, + bounds: { x: number; y: number } = { x: 0, y: 0 }, ): { x: number; y: number; width: number; height: number } { let width = startW; let height = startH; @@ -712,14 +711,16 @@ export function computeResize( // difference so the opposite (pinned) edge stays exactly in place — // clamping the position alone would let the bottom/right edge slide // while the user drags the top/left handle. - if ( x < EDGE_MARGIN ) { - const diff = EDGE_MARGIN - x; - x = EDGE_MARGIN; + const minX = bounds.x + EDGE_MARGIN; + const minY = bounds.y + EDGE_MARGIN; + if ( x < minX ) { + const diff = minX - x; + x = minX; width = Math.max( minWidth, width - diff ); } - if ( y < EDGE_MARGIN ) { - const diff = EDGE_MARGIN - y; - y = EDGE_MARGIN; + if ( y < minY ) { + const diff = minY - y; + y = minY; height = Math.max( minHeight, height - diff ); } diff --git a/src/work-area/index.ts b/src/work-area/index.ts index 517078c3..2e582875 100644 --- a/src/work-area/index.ts +++ b/src/work-area/index.ts @@ -37,6 +37,16 @@ * and anything a custom dock-rail renderer floats over it, since * every `.os-dock` in the shell body is measured. * + * **Reserved bands.** A surface that must never be covered — the Pulse + * Monitor's ECG strip — reserves an edge band with + * {@link reserveWorkArea}. A reservation is added to the insets, so + * everything that reads the work area (maximize, snap, the icon grid, + * widgets, notes, new-window placement) stays out of it. It is also + * stricter than a dock band: a dock band is only "where things go by + * default", and a user may still drag a window under the dock, but a + * reserved band bounds manual placement too ({@link placementRectOf}), + * so neither a drag nor a resize can push a window into it. + * * State lives in a {@link createSharedStore} slot: the Corkboard is a * lazy bundle with its own compiled copy of every module, and a * module-level variable here would be empty there. One store, every @@ -122,6 +132,119 @@ const store = createSharedStore< WorkAreaState >( WORK_AREA_STORE_KEY, () => ( { areaEl: null, } ) ); +/** A band a surface reserves along one edge of the desktop area. */ +export interface WorkAreaReservation { + edge: 'top' | 'right' | 'bottom' | 'left'; + /** Thickness in px. */ + size: number; +} + +const RESERVATION_EDGES: ReadonlyArray< WorkAreaReservation[ 'edge' ] > = [ 'top', 'right', 'bottom', 'left' ]; + +/** + * Reservations, in their own shared slot: a plugin bundle reserves, + * and the installer — which lives in the main bundle only — hears it + * through this store and re-measures. + */ +const reservations = createSharedStore< { bands: Array< { id: string } & WorkAreaReservation > } >( + `${ WORK_AREA_STORE_KEY }/reservations`, + () => ( { bands: [] } ), +); + +/** + * Reserve a band along one edge of the desktop area that nothing else + * may use: the work area shrinks by it, and manual window placement + * is bounded by it. Bands on the same edge stack in reservation order, + * after any dock band on that edge. Reserving an id again replaces its + * band in place. + * + * @param id Unique id (`plugin/purpose`). + * @param band Edge and thickness in px. + * @return Releases the band reserved under `id` (any later re-reservation + * included). Calling it twice is harmless. + */ +export function reserveWorkArea( id: string, band: WorkAreaReservation ): () => void { + if ( ! id || ! RESERVATION_EDGES.includes( band.edge ) ) { + return () => undefined; + } + const entry = { id, edge: band.edge, size: Math.max( 0, Math.round( Number( band.size ) || 0 ) ) }; + const bands = reservations.state.bands.slice(); + const at = bands.findIndex( ( b ) => b.id === id ); + if ( at >= 0 ) { + bands[ at ] = entry; + } else { + bands.push( entry ); + } + reservations.setState( { bands } ); + let released = false; + return () => { + if ( released ) { + return; + } + released = true; + // By id: an id names one purpose, so releasing it frees the band + // whatever size it was last re-reserved at. + if ( reservations.state.bands.some( ( b ) => b.id === id ) ) { + reservations.setState( { bands: reservations.state.bands.filter( ( b ) => b.id !== id ) } ); + } + }; +} + +/** The total reserved thickness on each edge. */ +export function getReservedInsets(): WorkAreaInsets { + const insets: WorkAreaInsets = { ...ZERO_INSETS }; + for ( const band of reservations.state.bands ) { + insets[ band.edge ] += band.size; + } + return insets; +} + +/** + * Where a band reserved under `id` sits, in viewport coordinates, or + * null when there is none (or the work area is not installed). A top + * or bottom band spans the work area's width; a side band its height. + */ +export function reservedRectOf( id: string ): WorkAreaRect | null { + const { installed, snapshot } = store.state; + const band = reservations.state.bands.find( ( b ) => b.id === id ); + if ( ! installed || ! band ) { + return null; + } + const { viewport } = snapshot; + // Bands stack outward-in after the chrome on their edge, so this + // one starts where the work area ends minus everything reserved + // after it on the same edge. + let after = 0; + for ( const other of reservations.state.bands.slice( reservations.state.bands.indexOf( band ) + 1 ) ) { + if ( other.edge === band.edge ) { + after += other.size; + } + } + switch ( band.edge ) { + case 'top': + return { x: viewport.x, y: viewport.y - after - band.size, width: viewport.width, height: band.size }; + case 'bottom': + return { x: viewport.x, y: viewport.y + viewport.height + after, width: viewport.width, height: band.size }; + case 'left': + return { x: viewport.x - after - band.size, y: viewport.y, width: band.size, height: viewport.height }; + default: + return { x: viewport.x + viewport.width + after, y: viewport.y, width: band.size, height: viewport.height }; + } +} + +/** + * The rectangle a user may place a window in by hand: the whole desktop + * area minus the reserved bands — but not minus dock bands, which a + * window may still be dragged under. Desktop-area-local, like + * {@link workAreaRectOf}. + */ +export function placementRectOf( areaEl?: HTMLElement | null ): WorkAreaRect { + const el = areaEl ?? store.state.areaEl; + const width = el ? el.clientWidth || el.getBoundingClientRect().width : store.state.snapshot.area.width; + const height = el ? el.clientHeight || el.getBoundingClientRect().height : store.state.snapshot.area.height; + return rectFromInsets( width, height, getReservedInsets() ); +} + /** Wiring the installer needs from the shell boot path. */ export interface WorkAreaInstallDeps { /** `#os-shell` — receives the CSS custom properties. */ @@ -265,7 +388,14 @@ export function measureWorkArea( deps: WorkAreaInstallDeps ): WorkAreaSnapshot { } chrome.push( node.getBoundingClientRect() ); } - const insets = computeInsets( areaRect, chrome ); + const chromeInsets = computeInsets( areaRect, chrome ); + const reserved = getReservedInsets(); + const insets: WorkAreaInsets = { + top: chromeInsets.top + reserved.top, + right: chromeInsets.right + reserved.right, + bottom: chromeInsets.bottom + reserved.bottom, + left: chromeInsets.left + reserved.left, + }; // `clientWidth` / `clientHeight` rather than the bounding rect's // size: the rect is fractional and transform-affected (the // desktop-switch slide animates `.os-area`), while the client @@ -449,6 +579,8 @@ export function installWorkArea( deps: WorkAreaInstallDeps ): WorkAreaController }; addAction( HOOKS.OVERVIEW_ENTERING, HOOKS_NAMESPACE, onOverviewEntering ); addAction( HOOKS.OVERVIEW_EXITED, HOOKS_NAMESPACE, onOverviewExited ); + // A band reserved or released from any bundle. + const unsubscribeReservations = reservations.subscribe( () => measure() ); observeRails(); measure(); @@ -465,6 +597,7 @@ export function installWorkArea( deps: WorkAreaInstallDeps ): WorkAreaController railObservers.clear(); document.removeEventListener( 'os-layout-changed', onLayoutChanged ); window.removeEventListener( 'resize', measure ); + unsubscribeReservations(); removeAction( HOOKS.OVERVIEW_ENTERING, HOOKS_NAMESPACE ); removeAction( HOOKS.OVERVIEW_EXITED, HOOKS_NAMESPACE ); if ( installed === controller ) { @@ -498,6 +631,7 @@ export function refreshWorkArea(): void { */ export function _resetWorkAreaForTests(): void { store.reset(); + reservations.reset(); } /** The shape plugins get on `wp.os.workArea`. */ @@ -510,6 +644,12 @@ export interface WorkAreaApi { insetsOf( element: Element ): WorkAreaInsets; /** Subscribe to changes; returns the unsubscribe function. */ subscribe( cb: ( snapshot: WorkAreaSnapshot ) => void ): () => void; + /** Reserve an edge band nothing else may use; returns the release function. */ + reserve( id: string, band: WorkAreaReservation ): () => void; + /** Viewport rect of the band reserved under `id`, or null. */ + reservedRectOf( id: string ): WorkAreaRect | null; + /** Rect manual window placement is bounded by (area minus reserved bands). */ + placementRectOf( areaEl?: HTMLElement | null ): WorkAreaRect; } export const workAreaApi: WorkAreaApi = { @@ -517,4 +657,7 @@ export const workAreaApi: WorkAreaApi = { rectOf: workAreaRectOf, insetsOf: workAreaInsetsOf, subscribe: subscribeWorkArea, + reserve: reserveWorkArea, + reservedRectOf, + placementRectOf, }; diff --git a/tests/phpunit/tests/pulseMonitor.php b/tests/phpunit/tests/pulseMonitor.php new file mode 100644 index 00000000..6bb27674 --- /dev/null +++ b/tests/phpunit/tests/pulseMonitor.php @@ -0,0 +1,456 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$editor_id = $factory->user->create( array( 'role' => 'editor' ) ); + } + + public function set_up() { + parent::set_up(); + openstation_pulse_reset(); + delete_option( OPENSTATION_PULSE_LOG_OPTION ); + delete_option( OPENSTATION_PULSE_LISTENERS_OPTION ); + delete_transient( 'health-check-site-status-result' ); + wp_set_current_user( 0 ); + } + + public function tear_down() { + openstation_pulse_reset(); + delete_option( OPENSTATION_PULSE_LOG_OPTION ); + delete_option( OPENSTATION_PULSE_LISTENERS_OPTION ); + delete_transient( 'health-check-site-status-result' ); + remove_all_filters( 'openstation_pulse_event' ); + remove_all_filters( 'openstation_pulse_request_kind' ); + remove_all_filters( 'openstation_pulse_capability' ); + remove_all_filters( 'openstation_pulse_health' ); + unset( $_SERVER['HTTP_USER_AGENT'], $_SERVER['HTTP_REFERER'], $_REQUEST['_wp_http_referer'] ); + parent::tear_down(); + } + + private function listen_as( $user_id ) { + update_option( OPENSTATION_PULSE_LISTENERS_OPTION, array( $user_id => time() + 100 ) ); + } + + private function log_entries() { + $log = get_option( OPENSTATION_PULSE_LOG_OPTION, array() ); + return isset( $log['entries'] ) ? $log['entries'] : array(); + } + + /* --------------------------------------------------------------------- + * Listener leases + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_on_shutdown + */ + public function test_nothing_is_recorded_without_a_listener() { + openstation_pulse_on_shutdown(); + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); + } + + /** + * @covers ::openstation_pulse_listeners + * @covers ::openstation_pulse_is_listening + */ + public function test_expired_leases_do_not_count() { + update_option( OPENSTATION_PULSE_LISTENERS_OPTION, array( self::$admin_id => time() - 1 ) ); + $this->assertFalse( openstation_pulse_is_listening() ); + $this->listen_as( self::$admin_id ); + $this->assertTrue( openstation_pulse_is_listening() ); + } + + /** + * @covers ::openstation_pulse_touch_listener + */ + public function test_touch_listener_writes_only_past_half_life() { + $this->assertTrue( openstation_pulse_touch_listener( self::$admin_id ) ); + $this->assertFalse( openstation_pulse_touch_listener( self::$admin_id ), 'A fresh lease is not rewritten.' ); + + update_option( OPENSTATION_PULSE_LISTENERS_OPTION, array( self::$admin_id => time() + 10 ) ); + $this->assertTrue( openstation_pulse_touch_listener( self::$admin_id ) ); + $this->assertFalse( openstation_pulse_touch_listener( 0 ) ); + } + + /* --------------------------------------------------------------------- + * Recorder + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_on_shutdown + * @covers ::openstation_pulse_classify_request + */ + public function test_visitor_request_is_recorded() { + $this->listen_as( self::$admin_id ); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15'; + openstation_pulse_on_shutdown(); + + $entries = $this->log_entries(); + $this->assertCount( 1, $entries ); + $this->assertSame( 'visitors', $entries[0]['kind'] ); + $this->assertSame( 1, $entries[0]['seq'] ); + $this->assertSame( 0, $entries[0]['user'] ); + } + + /** + * @covers ::openstation_pulse_is_bot + */ + public function test_bots_are_told_apart() { + $this->assertTrue( openstation_pulse_is_bot( 'Googlebot/2.1 (+http://www.google.com/bot.html)' ) ); + $this->assertTrue( openstation_pulse_is_bot( 'curl/8.4.0' ) ); + $this->assertTrue( openstation_pulse_is_bot( '' ) ); + $this->assertFalse( openstation_pulse_is_bot( 'Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0' ) ); + } + + /** + * @covers ::openstation_pulse_on_shutdown + */ + public function test_listener_own_requests_are_not_recorded() { + $this->listen_as( self::$admin_id ); + wp_set_current_user( self::$admin_id ); + openstation_pulse_on_shutdown(); + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); + } + + /** + * @covers ::openstation_pulse_on_shutdown + */ + public function test_listener_own_editor_requests_are_not_recorded() { + // The chromeless bridge reports the editor's requests from inside + // the editor window, so the server must not echo them back. + $this->listen_as( self::$admin_id ); + wp_set_current_user( self::$admin_id ); + $_SERVER['HTTP_REFERER'] = admin_url( 'post.php?post=12&action=edit' ); + openstation_pulse_on_shutdown(); + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); + } + + /** + * @covers ::openstation_pulse_on_shutdown + */ + public function test_other_users_admin_traffic_is_team() { + $this->listen_as( self::$admin_id ); + wp_set_current_user( self::$editor_id ); + set_current_screen( 'edit-post' ); + openstation_pulse_on_shutdown(); + set_current_screen( 'front' ); + + $entries = $this->log_entries(); + $this->assertSame( 'team', $entries[0]['kind'] ); + $this->assertSame( self::$editor_id, $entries[0]['user'] ); + } + + /** + * @covers ::openstation_pulse_record + * @covers ::openstation_pulse_on_comment + */ + public function test_explicit_events_replace_the_request_kind() { + $this->listen_as( self::$admin_id ); + $post_id = self::factory()->post->create(); + self::factory()->comment->create( array( 'comment_post_ID' => $post_id ) ); + openstation_pulse_on_shutdown(); + + $kinds = wp_list_pluck( $this->log_entries(), 'kind' ); + $this->assertContains( 'comments', $kinds ); + $this->assertNotContains( 'visitors', $kinds ); + } + + /** + * @covers ::openstation_pulse_on_comment + */ + public function test_bookkeeping_comment_types_do_not_beat() { + $this->listen_as( self::$admin_id ); + $post_id = self::factory()->post->create(); + openstation_pulse_reset(); // Publishing the post beat already. + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'order_note', + ) + ); + $state = openstation_pulse_state(); + $this->assertSame( array(), $state['events'], 'A WooCommerce order note is not a comment.' ); + + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'review', + ) + ); + $state = openstation_pulse_state(); + $this->assertSame( array( 'comments' ), wp_list_pluck( $state['events'], 'kind' ), 'A product review is.' ); + } + + /** + * @covers ::openstation_pulse_on_transition_post_status + */ + public function test_publishing_beats_once() { + $this->listen_as( self::$admin_id ); + $post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + openstation_pulse_reset(); + wp_publish_post( $post_id ); + wp_update_post( array( 'ID' => $post_id, 'post_title' => 'Edited' ) ); + openstation_pulse_on_shutdown(); + + $this->assertSame( array( 'publish' ), wp_list_pluck( $this->log_entries(), 'kind' ) ); + } + + /** + * @covers ::openstation_pulse_record + */ + public function test_failed_login_is_an_error_beat() { + $this->listen_as( self::$admin_id ); + do_action( 'wp_login_failed', 'nobody', new WP_Error() ); + openstation_pulse_on_shutdown(); + + $entries = $this->log_entries(); + $this->assertSame( 'logins', $entries[0]['kind'] ); + $this->assertTrue( $entries[0]['error'] ); + } + + /** + * @covers ::openstation_pulse_record + */ + public function test_record_rejects_non_slug_kinds() { + $this->assertFalse( openstation_pulse_record( '' ) ); + $this->assertFalse( openstation_pulse_record( array( 'x' ) ) ); + $this->assertTrue( openstation_pulse_record( 'My-Kind' ) ); + } + + /** + * @covers ::openstation_pulse_on_shutdown + */ + public function test_event_filter_can_drop_and_relabel() { + $this->listen_as( self::$admin_id ); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (X11; Linux x86_64) Firefox/131.0'; + add_filter( + 'openstation_pulse_event', + function ( $row ) { + return 'visitors' === $row['kind'] ? false : $row; + } + ); + openstation_pulse_on_shutdown(); + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); + + remove_all_filters( 'openstation_pulse_event' ); + add_filter( + 'openstation_pulse_request_kind', + function () { + return 'orders'; + } + ); + openstation_pulse_on_shutdown(); + $this->assertSame( 'orders', $this->log_entries()[0]['kind'] ); + } + + /** + * @covers ::openstation_pulse_append + */ + public function test_log_is_pruned_by_count_and_age() { + $now = (int) round( microtime( true ) * 1000 ); + $rows = array_fill( + 0, + OPENSTATION_PULSE_LOG_MAX + 25, + array( + 'kind' => 'visitors', + 'status' => 200, + 'error' => false, + 'user' => 0, + ) + ); + openstation_pulse_append( $rows, $now ); + $this->assertCount( OPENSTATION_PULSE_LOG_MAX, $this->log_entries() ); + + openstation_pulse_append( array( $rows[0] ), $now + OPENSTATION_PULSE_LOG_WINDOW_MS + 1 ); + $this->assertCount( 1, $this->log_entries() ); + $this->assertSame( OPENSTATION_PULSE_LOG_MAX + 26, $this->log_entries()[0]['seq'] ); + } + + /* --------------------------------------------------------------------- + * Heartbeat relay + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_read_log + */ + public function test_read_log_returns_the_sequence_and_the_windowed_rows() { + $now = (int) round( microtime( true ) * 1000 ); + update_option( + OPENSTATION_PULSE_LOG_OPTION, + array( + 'seq' => 7, + 'entries' => array( + array( 'seq' => 6, 'ts' => $now - OPENSTATION_PULSE_LOG_WINDOW_MS - 1, 'kind' => 'visitors' ), + array( 'seq' => 7, 'ts' => $now - 1000, 'kind' => 'bots' ), + 'not a row', + ), + ) + ); + + $log = openstation_pulse_read_log( $now ); + $this->assertSame( 7, $log['seq'] ); + $this->assertSame( array( 'bots' ), wp_list_pluck( $log['entries'], 'kind' ) ); + + delete_option( OPENSTATION_PULSE_LOG_OPTION ); + $this->assertSame( array( 'seq' => 0, 'entries' => array() ), openstation_pulse_read_log( $now ) ); + } + + /** + * @covers ::openstation_pulse_heartbeat_received + */ + public function test_heartbeat_without_opt_in_is_untouched() { + wp_set_current_user( self::$admin_id ); + $this->assertSame( array( 'x' => 1 ), openstation_pulse_heartbeat_received( array( 'x' => 1 ), array() ) ); + } + + /** + * @covers ::openstation_pulse_heartbeat_received + */ + public function test_heartbeat_requires_capability() { + wp_set_current_user( self::$editor_id ); + $response = openstation_pulse_heartbeat_received( array(), array( 'openstation_pulse' => array( 'since' => 0 ) ) ); + $this->assertArrayNotHasKey( 'openstation_pulse', $response ); + $this->assertFalse( openstation_pulse_is_listening(), 'A denied user does not open a lease.' ); + + add_filter( + 'openstation_pulse_capability', + function () { + return 'edit_posts'; + } + ); + $response = openstation_pulse_heartbeat_received( array(), array( 'openstation_pulse' => array( 'since' => 0 ) ) ); + $this->assertArrayHasKey( 'openstation_pulse', $response ); + } + + /** + * @covers ::openstation_pulse_heartbeat_received + */ + public function test_heartbeat_relays_fresh_events_from_other_users_only() { + $now = (int) round( microtime( true ) * 1000 ); + openstation_pulse_append( + array( + array( 'kind' => 'visitors', 'status' => 200, 'error' => false, 'user' => 0 ), + array( 'kind' => 'team', 'status' => 200, 'error' => false, 'user' => self::$admin_id ), + array( 'kind' => 'bots', 'status' => 503, 'error' => true, 'user' => 0 ), + ), + $now + ); + wp_set_current_user( self::$admin_id ); + + $first = openstation_pulse_heartbeat_received( array(), array( 'openstation_pulse' => array( 'since' => 0 ) ) ); + $this->assertSame( 3, $first['openstation_pulse']['seq'] ); + $this->assertSame( array(), $first['openstation_pulse']['events'], 'The first tick does not replay history.' ); + $this->assertTrue( openstation_pulse_is_listening(), 'An opted-in tick opens a lease.' ); + + $next = openstation_pulse_heartbeat_received( array(), array( 'openstation_pulse' => array( 'since' => 1 ) ) ); + $events = $next['openstation_pulse']['events']; + $this->assertSame( array( 'bots' ), wp_list_pluck( $events, 'kind' ) ); + $this->assertTrue( $events[0]['error'] ); + $this->assertArrayNotHasKey( 'user', $events[0], 'User ids never leave the server.' ); + } + + /** + * @covers ::openstation_pulse_heartbeat_received + */ + public function test_heartbeat_relays_everything_after_a_log_reset() { + wp_set_current_user( self::$admin_id ); + // The client's cursor is from before the log was deleted. + openstation_pulse_append( + array( + array( 'kind' => 'visitors', 'status' => 200, 'error' => false, 'user' => 0 ), + array( 'kind' => 'comments', 'status' => 302, 'error' => false, 'user' => 0 ), + ), + (int) round( microtime( true ) * 1000 ) + ); + + $tick = openstation_pulse_heartbeat_received( array(), array( 'openstation_pulse' => array( 'since' => 500 ) ) ); + $this->assertSame( array( 'visitors', 'comments' ), wp_list_pluck( $tick['openstation_pulse']['events'], 'kind' ) ); + $this->assertSame( 2, $tick['openstation_pulse']['seq'], 'The cursor comes back down with the response.' ); + } + + /* --------------------------------------------------------------------- + * Health snapshot + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_health_snapshot + */ + public function test_health_snapshot_reads_site_health_and_failures() { + set_transient( + 'health-check-site-status-result', + wp_json_encode( + array( + 'good' => 12, + 'recommended' => 3, + 'critical' => 1, + ) + ) + ); + $snapshot = openstation_pulse_health_snapshot( + array( + array( 'error' => true ), + array( 'error' => false ), + ) + ); + $this->assertSame( array( 'good' => 12, 'recommended' => 3, 'critical' => 1 ), $snapshot['siteHealth'] ); + $this->assertSame( 2, $snapshot['requests'] ); + $this->assertSame( 1, $snapshot['failures'] ); + $this->assertIsInt( $snapshot['cronLate'] ); + $this->assertSame( array( 'siteHealth', 'requests', 'failures', 'cronLate' ), array_keys( $snapshot ) ); + } + + /** + * @covers ::openstation_pulse_health_snapshot + */ + public function test_health_snapshot_is_filterable() { + add_filter( + 'openstation_pulse_health', + function ( $snapshot ) { + $snapshot['failures'] = 99; + return $snapshot; + } + ); + $this->assertSame( 99, openstation_pulse_health_snapshot( array() )['failures'] ); + $this->assertNull( openstation_pulse_health_snapshot( array() )['siteHealth'] ); + } + + /* --------------------------------------------------------------------- + * Registration + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_register_assets + */ + public function test_one_script_serves_both_faces() { + openstation_pulse_register_assets(); + $this->assertTrue( wp_script_is( 'os-pulse-monitor', 'registered' ) ); + $this->assertContains( 'heartbeat', wp_scripts()->registered['os-pulse-monitor']->deps ); + $this->assertSame( 10, has_filter( 'heartbeat_received', 'openstation_pulse_heartbeat_received' ) ); + $this->assertSame( 10, has_action( 'shutdown', 'openstation_pulse_on_shutdown' ) ); + } + + /** + * @covers ::openstation_pulse_register_widget + */ + public function test_widget_is_registered_on_the_shared_script() { + openstation_pulse_register_widget(); + $widget = openstation_desktop_widget_registry( 'desktop-mode/pulse' ); + $this->assertIsArray( $widget ); + $this->assertSame( 'os-pulse-monitor', $widget['script'] ); + $this->assertTrue( $widget['resizable'] ); + } +} diff --git a/tests/vitest/drag-unstate.test.ts b/tests/vitest/drag-unstate.test.ts index 3253ce30..ccf47db2 100644 --- a/tests/vitest/drag-unstate.test.ts +++ b/tests/vitest/drag-unstate.test.ts @@ -413,6 +413,12 @@ describe( 'clampWindowPosition', () => { expect( clampWindowPosition( 100, 2000, 800, bounds ) ).toEqual( { x: 100, y: 860 } ); } ); + test( 'a reserved top band is a hard top limit for a dragged window', () => { + // `placementRectOf()` with a 160px band reserved along the top. + const placement = { x: 0, y: 160, width: 1600, height: 740 }; + expect( clampWindowPosition( 100, 5, 800, placement ) ).toEqual( { x: 100, y: 160 } ); + } ); + test( 'clamps against the work area, not the desktop area', () => { // A bottom dock pill claiming 80px, a left rail claiming 60px. const workArea = { x: 60, y: 0, width: 1540, height: 820 }; diff --git a/tests/vitest/embed-admin-page.test.ts b/tests/vitest/embed-admin-page.test.ts index 8f91e9c5..d29c6823 100644 --- a/tests/vitest/embed-admin-page.test.ts +++ b/tests/vitest/embed-admin-page.test.ts @@ -184,7 +184,7 @@ describe( 'the embedded page’s error and network reports', () => { post( iframe, { type: 'os-iframe-network', method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false } ); expect( seen ).toEqual( [ - { windowId: WINDOW_ID, method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false }, + { windowId: WINDOW_ID, method: 'post', url: '/wp-json/wp/v2/posts/7', status: 200, duration: 84, failed: false, aborted: false }, ] ); teardown(); } ); diff --git a/tests/vitest/notes-layer.test.ts b/tests/vitest/notes-layer.test.ts index 90bc3c35..7b159330 100644 --- a/tests/vitest/notes-layer.test.ts +++ b/tests/vitest/notes-layer.test.ts @@ -9,6 +9,7 @@ import { __resetNotesHeartbeatForTests } from '../../src/notes/heartbeat'; import { __resetNotesRestForTests, installNotesRestDeps } from '../../src/notes/rest'; import type { Note } from '../../src/notes/types'; import { clearHooksStub, installHooksStub } from './helpers/hooks-stub'; +import { _resetWorkAreaForTests, installWorkArea, reserveWorkArea } from '../../src/work-area'; function makeNote( overrides: Partial< Note > = {} ): Note { return { @@ -130,6 +131,32 @@ describe( 'NotesLayer', () => { expect( controller.element.dataset.noteColor ).toBe( 'seafoam' ); } ); + test( 'a reserved band moves notes out of it, display only, and back', () => { + _resetWorkAreaForTests(); + const layer = makeLayer(); + // The layer's host as the measured desktop area. + const shell = document.createElement( 'div' ); + const body = document.createElement( 'div' ); + shell.append( body ); + body.append( layer.host ); + document.body.append( shell ); + const ctl = installWorkArea( { shell, shellBody: body, area: layer.host } ); + const controller = layer.upsertNote( makeNote( { id: 1, x: 0.1, y: 0.02 } ) ); + expect( parseFloat( controller.element.style.top ) ).toBe( 2 ); + + const release = reserveWorkArea( 'test/band', { edge: 'top', size: 100 } ); + controller.applyPosition(); + // 100px of a 500px host: the note is drawn at 20 %, not 2 %. + expect( parseFloat( controller.element.style.top ) ).toBe( 20 ); + expect( controller.note.y ).toBe( 0.02 ); + + release(); + controller.applyPosition(); + expect( parseFloat( controller.element.style.top ) ).toBe( 2 ); + ctl.destroy(); + _resetWorkAreaForTests(); + } ); + test( 'remote z changes apply to the element on replace', () => { const layer = makeLayer(); const controller = layer.upsertNote( makeNote( { id: 1, z: 3 } ) ); diff --git a/tests/vitest/pulse-monitor.test.ts b/tests/vitest/pulse-monitor.test.ts new file mode 100644 index 00000000..619fd3f4 --- /dev/null +++ b/tests/vitest/pulse-monitor.test.ts @@ -0,0 +1,631 @@ +/** + * Pulse Monitor — settings sanitization, the health diagnosis, the + * rhythm engine, the spike text, and the shared signal hub. + * + * The rhythm tests drive the engine's clock directly, so they pin the + * contract the scene relies on: activity beats queue behind a + * refractory gap, a quiet site keeps a resting rhythm, a site that is + * down conducts nothing, and each condition draws its own morphology. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { activity } from '../../src/activity'; +import { installHooksStub, clearHooksStub } from './helpers/hooks-stub'; +import { doAction, HOOKS } from '../../src/hooks'; +import { acquirePulseHub } from '../../src/plugins/pulse-monitor/hub'; +import { spikeText } from '../../src/plugins/pulse-monitor/monitor'; +import { + PULSE_DEFAULTS, + parseMuted, + sanitizePulseSettings, + serializeMuted, +} from '../../src/plugins/pulse-monitor/settings'; +import { HealthMonitor } from '../../src/plugins/pulse-monitor/health'; +import { Beeper, createReadout } from '../../src/plugins/pulse-monitor/readout'; +import { IncidentLog, INCIDENT_MAX } from '../../src/plugins/pulse-monitor/incidents'; +import { renderIssues } from '../../src/plugins/pulse-monitor/issues'; +import { renderPulseConfig } from '../../src/plugins/pulse-monitor/config'; +import { Rhythm, type BeatStarted } from '../../src/plugins/pulse-monitor/rhythm'; +import { shapeFor, sourceCatalog, SOURCE_IDS } from '../../src/plugins/pulse-monitor/sources'; + +/** Deterministic PRNG (mulberry32). */ +function seeded( seed: number ): () => number { + let a = seed; + return () => { + a |= 0; + a = ( a + 0x6d2b79f5 ) | 0; + let t = Math.imul( a ^ ( a >>> 15 ), 1 | a ); + t = ( t + Math.imul( t ^ ( t >>> 7 ), 61 | t ) ) ^ t; + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + }; +} + +/** Run the engine for `ms`, stepping every 5 ms; returns the samples. */ +function run( rhythm: Rhythm, from: number, ms: number ): number[] { + const samples: number[] = []; + for ( let t = from; t <= from + ms; t += 5 ) { + rhythm.step( t ); + samples.push( rhythm.sample( t ).value ); + } + return samples; +} + +describe( 'sanitizePulseSettings', () => { + test( 'empty input falls back to defaults', () => { + expect( sanitizePulseSettings( undefined ) ).toEqual( PULSE_DEFAULTS ); + expect( sanitizePulseSettings( {} ) ).toEqual( PULSE_DEFAULTS ); + } ); + + test( 'the defaults are a black desk with a red line', () => { + expect( PULSE_DEFAULTS.background ).toBe( '#000000' ); + expect( PULSE_DEFAULTS.lineColor ).toBe( '#ff2a2a' ); + } ); + + test( 'clamps numbers and rejects malformed values', () => { + const s = sanitizePulseSettings( { + speed: 99999, + intensity: -4, + echoes: 7.6, + lineColor: 'red', + background: '#ABCDEF', + style: 'spiral', + grid: 'yes', + } ); + expect( s.speed ).toBe( 600 ); + expect( s.intensity ).toBe( 0.3 ); + expect( s.echoes ).toBe( 8 ); + expect( s.lineColor ).toBe( PULSE_DEFAULTS.lineColor ); + expect( s.background ).toBe( '#abcdef' ); + expect( s.style ).toBe( 'sweep' ); + expect( s.grid ).toBe( false ); + } ); + + test( 'muted sources round-trip through one comma string', () => { + expect( parseMuted( 'assets, input,bogus,assets' ) ).toEqual( [ 'assets', 'input' ] ); + expect( serializeMuted( [ 'assets', 'input' ] ) ).toBe( 'assets,input' ); + expect( sanitizePulseSettings( { muted: 'cron' } ).muted ).toEqual( [ 'cron' ] ); + } ); + + test( 'settings stay under the shell’s 32-key-per-wallpaper cap', () => { + expect( Object.keys( PULSE_DEFAULTS ).length ).toBeLessThanOrEqual( 32 ); + } ); +} ); + +describe( 'sources', () => { + test( 'catalog covers every id, split client / server', () => { + const catalog = sourceCatalog(); + expect( catalog.map( ( s ) => s.id ) ).toEqual( [ ...SOURCE_IDS ] ); + expect( catalog.find( ( s ) => s.id === 'visitors' )?.group ).toBe( 'server' ); + expect( catalog.find( ( s ) => s.id === 'requests' )?.group ).toBe( 'client' ); + } ); + + test( 'unknown sources (plugin kinds) get a standard beat', () => { + expect( shapeFor( 'my-plugin-kind' ).shape ).toBe( 'qrs' ); + expect( shapeFor( 'heartbeat' ).shape ).toBe( 'double' ); + } ); +} ); + +describe( 'HealthMonitor', () => { + test( 'a quiet or clean site is healthy', () => { + const h = new HealthMonitor(); + expect( h.read( 0 ).state ).toBe( 'healthy' ); + for ( let i = 0; i < 20; i++ ) { + h.recordRequest( i * 100, 200 ); + h.recordLatency( i * 100, 120 ); + } + const r = h.read( 2000 ); + expect( r.state ).toBe( 'healthy' ); + expect( r.successRate ).toBe( 1 ); + expect( r.latencyP50 ).toBe( 120 ); + } ); + + test( 'failing requests degrade the rhythm, worst first in the reasons', () => { + const h = new HealthMonitor(); + for ( let i = 0; i < 20; i++ ) { + h.recordRequest( i * 100, i % 2 ? 503 : 200 ); + } + // Smoothing settles after a few time constants. + let r = h.read( 2000 ); + for ( let t = 2500; t <= 40000; t += 500 ) { + r = h.read( t > 59000 ? 59000 : t ); + } + expect( [ 'sick', 'critical' ] ).toContain( r.state ); + expect( r.reasons[ 0 ] ).toMatch( /^50%{1,2} of requests failing$/ ); + } ); + + test( 'server snapshot feeds the score', () => { + const h = new HealthMonitor(); + h.setServer( 0, { siteHealth: { good: 10, recommended: 0, critical: 3 }, requests: 10, failures: 0, cronLate: 0 } ); + let r = h.read( 0 ); + for ( let t = 0; t <= 60000; t += 1000 ) { + r = h.read( t ); + } + expect( r.score ).toBe( 70 ); + expect( r.state ).toBe( 'strained' ); + } ); + + test( 'a slow, misconfigured site is never critical — only failing requests are', () => { + const settle = ( h: HealthMonitor ): ReturnType< HealthMonitor[ 'read' ] > => { + let r = h.read( 0 ); + for ( let t = 0; t <= 50000; t += 1000 ) { + r = h.read( t ); + } + return r; + }; + const slow = new HealthMonitor(); + slow.setServer( 0, { siteHealth: { good: 1, recommended: 9, critical: 3 }, requests: 0, failures: 0, cronLate: 999999 } ); + for ( let i = 0; i < 20; i++ ) { + slow.recordRequest( i * 100, 200 ); + slow.recordLatency( i * 100, 4000 ); + slow.recordJsError( i * 100 ); + } + const r = settle( slow ); + expect( r.score ).toBeLessThan( 30 ); + expect( r.state ).toBe( 'sick' ); + + const config = new HealthMonitor(); + config.setServer( 0, { siteHealth: { good: 1, recommended: 9, critical: 9 }, requests: 0, failures: 0, cronLate: 999999 } ); + expect( settle( config ).state ).toBe( 'strained' ); + + const failing = new HealthMonitor(); + for ( let i = 0; i < 20; i++ ) { + failing.recordRequest( i * 100, 503 ); + } + const f = settle( failing ); + expect( f.state ).toBe( 'critical' ); + expect( f.cause ).toMatch( /of requests failing/ ); + // The live case: a slow, misconfigured site where a few requests + // (5 %) also fail. Low score — but fibrillation, not VT, and its + // beats blame the slowness, not the failures. + const mixed = new HealthMonitor(); + mixed.setServer( 0, { siteHealth: { good: 1, recommended: 9, critical: 2 }, requests: 0, failures: 0, cronLate: 999999 } ); + for ( let i = 0; i < 40; i++ ) { + mixed.recordRequest( i * 100, i % 20 === 0 ? 404 : 200 ); + mixed.recordLatency( i * 100, 3400 ); + mixed.recordJsError( i * 100 ); + } + const m = settle( mixed ); + expect( m.state ).toBe( 'sick' ); + expect( m.cause ).toMatch( /slow responses|JavaScript errors/ ); + + } ); + + test( 'one long request cannot make the site look slow', () => { + const h = new HealthMonitor(); + // A 20-second AI search among ordinary requests. + h.recordLatency( 0, 20000 ); + for ( let i = 1; i < 20; i++ ) { + h.recordLatency( i * 100, 150 ); + } + const r = h.read( 2000 ); + expect( r.latencyP95 ).toBe( 150 ); + expect( r.reasons.join( ' ' ) ).not.toMatch( /slow responses/ ); + + // Too few samples: no p95 at all, however slow they were. + const few = new HealthMonitor(); + for ( let i = 0; i < 5; i++ ) { + few.recordLatency( i * 100, 20000 ); + } + expect( few.read( 1000 ).latencyP95 ).toBeNull(); + // And one request counts for at most 10 s. + expect( few.read( 1000 ).latencyP50 ).toBe( 10000 ); + } ); + + test( 'offline, a lost Heartbeat connection or a run of network failures is down', () => { + const offline = new HealthMonitor(); + offline.setOffline( true ); + expect( offline.read( 0 ).state ).toBe( 'down' ); + + const lost = new HealthMonitor(); + lost.setHeartbeatLost( true ); + expect( lost.read( 0 ).state ).toBe( 'down' ); + + const failing = new HealthMonitor(); + for ( let i = 0; i < 5; i++ ) { + failing.recordRequest( i, 0 ); + } + expect( failing.read( 10 ).state ).toBe( 'down' ); + failing.recordRequest( 20, 200 ); + expect( failing.read( 30 ).state ).not.toBe( 'down' ); + } ); + + test( 'observations age out of the window', () => { + const h = new HealthMonitor( 1000 ); + for ( let i = 0; i < 10; i++ ) { + h.recordRequest( i, 500 ); + } + h.read( 10 ); + expect( h.read( 5000 ).successRate ).toBeNull(); + } ); +} ); + +describe( 'Rhythm', () => { + test( 'a quiet site keeps its resting rate', () => { + const rhythm = new Rhythm( { restingBpm: 60, random: seeded( 1 ) } ); + run( rhythm, 0, 15000 ); + expect( rhythm.heartRate() ).toBeGreaterThanOrEqual( 56 ); + expect( rhythm.heartRate() ).toBeLessThanOrEqual( 64 ); + } ); + + test( 'resting 0 flatlines when quiet', () => { + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 2 ) } ); + const samples = run( rhythm, 0, 5000 ); + expect( Math.max( ...samples.map( Math.abs ) ) ).toBeLessThan( 0.1 ); + } ); + + test( 'a burst queues behind the refractory gap instead of merging', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 3 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.step( 0 ); + for ( let i = 0; i < 5; i++ ) { + rhythm.push( { source: 'requests' } ); + } + run( rhythm, 0, 1000 ); + // 300 ms gap → beats at 0, 300, 600, 900. + expect( started.filter( ( b ) => b.major ) ).toHaveLength( 4 ); + run( rhythm, 1005, 500 ); + expect( started.filter( ( b ) => b.major ) ).toHaveLength( 5 ); + } ); + + test( 'delayed pushes start at their time (server replay)', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 4 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.step( 0 ); + rhythm.push( { source: 'visitors' }, 2000 ); + run( rhythm, 0, 1900 ); + expect( started ).toHaveLength( 0 ); + run( rhythm, 1905, 200 ); + expect( started.map( ( b ) => b.source ) ).toEqual( [ 'visitors' ] ); + } ); + + test( 'failed requests draw as ectopic beats', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 5 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.step( 0 ); + rhythm.push( { source: 'requests', error: true } ); + run( rhythm, 0, 400 ); + expect( started[ 0 ] ).toMatchObject( { shape: 'pvc', error: true } ); + } ); + + test( 'each condition has its own morphology', () => { + const shapesIn = ( state: 'healthy' | 'strained' | 'sick' | 'critical' ): Set< string > => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 70, random: seeded( 6 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.setState( state ); + run( rhythm, 0, 30000 ); + return new Set( started.map( ( b ) => `${ b.shape }:${ b.error }` ) ); + }; + expect( shapesIn( 'healthy' ) ).toEqual( new Set( [ 'qrs:false' ] ) ); + expect( shapesIn( 'strained' ) ).toEqual( new Set( [ 'qrs:false', 'pvc:true' ] ) ); + expect( shapesIn( 'critical' ) ).toEqual( new Set( [ 'vt:true' ] ) ); + expect( shapesIn( 'sick' ) ).toEqual( new Set( [ 'qrs:false' ] ) ); + } ); + + test( 'atrial fibrillation is irregular; sinus is regular', () => { + const intervals = ( state: 'healthy' | 'sick' ): number => { + const times: number[] = []; + let t = 0; + const rhythm = new Rhythm( { restingBpm: 70, random: seeded( 7 ), onBeat: () => times.push( t ) } ); + rhythm.setState( state ); + for ( t = 0; t <= 30000; t += 5 ) { + rhythm.step( t ); + } + const gaps = times.slice( 1 ).map( ( v, i ) => v - times[ i ] ); + const mean = gaps.reduce( ( a, b ) => a + b, 0 ) / gaps.length; + const sd = Math.sqrt( gaps.reduce( ( a, b ) => a + ( b - mean ) ** 2, 0 ) / gaps.length ); + return sd / mean; + }; + expect( intervals( 'healthy' ) ).toBeLessThan( 0.05 ); + expect( intervals( 'sick' ) ).toBeGreaterThan( 0.15 ); + } ); + + test( 'a site that is down conducts nothing but still counts signals', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 70, random: seeded( 8 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.setState( 'down' ); + rhythm.step( 0 ); + rhythm.push( { source: 'requests' } ); + const samples = run( rhythm, 0, 5000 ); + expect( started ).toHaveLength( 0 ); + expect( Math.max( ...samples.map( Math.abs ) ) ).toBeLessThan( 0.05 ); + expect( rhythm.eventsPerMinute() ).toBe( 2 ); + } ); +} ); + +describe( 'failed beats explain themselves', () => { + test( 'a failed request carries its status into the beat', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 9 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.step( 0 ); + rhythm.push( { source: 'requests', error: true, detail: '404' } ); + run( rhythm, 0, 400 ); + expect( started[ 0 ] ).toMatchObject( { error: true, detail: '404', ectopic: false } ); + expect( spikeText( started[ 0 ] ) ).toBe( 'request · 404' ); + } ); + + test( 'a normal beat has no detail', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 0, random: seeded( 10 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.step( 0 ); + rhythm.push( { source: 'visitors', detail: 'ignored' } ); + run( rhythm, 0, 400 ); + expect( spikeText( started[ 0 ] ) ).toBe( 'visit' ); + } ); + + test( 'beats the diagnosis turns abnormal name the rhythm and its cause', () => { + const started: BeatStarted[] = []; + const rhythm = new Rhythm( { restingBpm: 70, random: seeded( 11 ), onBeat: ( b ) => started.push( b ) } ); + rhythm.setState( 'strained', 'slow responses (p95 3.1 s)' ); + run( rhythm, 0, 30000 ); + const pvc = started.find( ( b ) => b.ectopic ); + expect( pvc ).toBeDefined(); + expect( spikeText( pvc as BeatStarted ) ).toBe( 'PVC: slow responses (p95 3.1 s)' ); + + const vt: BeatStarted[] = []; + const critical = new Rhythm( { restingBpm: 70, random: seeded( 12 ), onBeat: ( b ) => vt.push( b ) } ); + critical.setState( 'critical', '42% of requests failing' ); + critical.step( 0 ); + critical.push( { source: 'requests' } ); + run( critical, 0, 400 ); + expect( spikeText( vt[ 0 ] ) ).toBe( 'request · VT: 42% of requests failing' ); + } ); +} ); + +describe( 'shared hub', () => { + beforeEach( () => installHooksStub() ); + afterEach( () => clearHooksStub() ); + + test( 'two monitors share one set of taps', () => { + const a = acquirePulseHub(); + const b = acquirePulseHub(); + const seen: string[] = []; + const off = a.hub.subscribe( ( beat ) => seen.push( beat.source ) ); + + activity.publish( 'os/request-settled', { url: '/x', method: 'GET', status: 200, ok: true, windowId: null, silent: false } ); + expect( seen ).toEqual( [ 'requests' ] ); + expect( a.hub.health ).toBe( b.hub.health ); + + b.release(); + activity.publish( 'os/request-settled', { url: '/y', method: 'GET', status: 500, ok: false, windowId: null, silent: false } ); + expect( seen ).toEqual( [ 'requests', 'requests' ] ); + + a.release(); + activity.publish( 'os/request-settled', { url: '/z', method: 'GET', status: 200, ok: true, windowId: null, silent: false } ); + expect( seen ).toHaveLength( 2 ); + off(); + } ); + + test( 'window network reports beat — the editor’s included — without a messages tick', () => { + const { hub, release } = acquirePulseHub(); + const beats: Array< { source: string; error?: boolean; detail?: string } > = []; + const off = hub.subscribe( ( beat ) => beats.push( beat ) ); + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, { windowId: 'desktop-mode-posts', method: 'POST', url: '/wp-json/wp/v2/posts/1', status: 200, duration: 120, failed: false } ); + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, { windowId: 'edit-php', method: 'GET', url: '/x', status: 500, duration: 80, failed: true } ); + // The bridge message the shell relays from is not a "messages" beat. + window.dispatchEvent( new MessageEvent( 'message', { data: { type: 'os-iframe-network' }, origin: window.location.origin } ) ); + expect( beats ).toEqual( [ + expect.objectContaining( { source: 'requests', error: false } ), + expect.objectContaining( { source: 'requests', error: true, detail: '500' } ), + ] ); + off(); + release(); + } ); + + test( 'a request the page cancelled is neither a beat nor a failure', () => { + const { hub, release } = acquirePulseHub(); + const beats: unknown[] = []; + const off = hub.subscribe( ( beat ) => beats.push( beat ) ); + for ( let i = 0; i < 8; i++ ) { + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, { windowId: 'w', method: 'GET', url: `/search?q=${ i }`, status: 0, duration: 30, failed: true, aborted: true } ); + } + expect( beats ).toEqual( [] ); + const reading = hub.health.read( performance.now() ); + expect( reading.state ).not.toBe( 'down' ); + expect( reading.successRate ).toBeNull(); + off(); + release(); + } ); + + test( 'a window page’s taps are released when it unloads or its frame goes away', () => { + vi.useFakeTimers(); + const frame = ( path: string ): { iframe: HTMLIFrameElement; doc: Document; removed: string[] } => { + const iframe = document.createElement( 'iframe' ); + document.body.appendChild( iframe ); + const doc = iframe.contentDocument as Document; + // jsdom frames are about:blank, which the scan skips. + Object.defineProperty( doc, 'URL', { value: `${ window.location.origin }${ path }` } ); + const removed: string[] = []; + const original = doc.removeEventListener.bind( doc ); + doc.removeEventListener = ( ( type: string, ...rest: [ EventListenerOrEventListenerObject, EventListenerOptions? ] ) => { + removed.push( type ); + original( type, ...rest ); + } ) as typeof doc.removeEventListener; + return { iframe, doc, removed }; + }; + const navigated = frame( '/wp-admin/edit.php' ); + const closed = frame( '/wp-admin/upload.php' ); + const { release } = acquirePulseHub(); + + // A navigation inside the window: the old document unloads. + navigated.iframe.contentWindow?.dispatchEvent( new Event( 'pagehide' ) ); + expect( navigated.removed.sort() ).toEqual( [ 'keydown', 'pointerdown', 'wheel' ] ); + + // A closed window: the frame leaves the shell; the next scan lets go. + expect( closed.removed ).toEqual( [] ); + closed.iframe.remove(); + vi.advanceTimersByTime( 1600 ); + expect( closed.removed.sort() ).toEqual( [ 'keydown', 'pointerdown', 'wheel' ] ); + + release(); + navigated.iframe.remove(); + vi.useRealTimers(); + } ); + + test( 'failed requests carry their status', () => { + const { hub, release } = acquirePulseHub(); + const beats: Array< { error?: boolean; detail?: string } > = []; + const off = hub.subscribe( ( beat ) => beats.push( beat ) ); + activity.publish( 'os/request-settled', { url: '/a', method: 'GET', status: 503, ok: false, windowId: null, silent: false } ); + activity.publish( 'os/request-settled', { url: '/b', method: 'GET', error: 'Failed to fetch', windowId: null, silent: false } ); + expect( beats ).toEqual( [ + expect.objectContaining( { error: true, detail: '503' } ), + expect.objectContaining( { error: true, detail: 'Failed to fetch' } ), + ] ); + off(); + release(); + } ); +} ); + +describe( 'Beeper', () => { + test( 'no audio context exists while sound is off', () => { + const made: Array< { closed: boolean } > = []; + class FakeContext { + state = 'running'; + closed = false; + constructor() { + made.push( this ); + } + resume(): Promise< void > { + return Promise.resolve(); + } + close(): Promise< void > { + this.closed = true; + return Promise.resolve(); + } + } + const previous = window.AudioContext; + ( window as unknown as { AudioContext: unknown } ).AudioContext = FakeContext; + try { + const beeper = new Beeper(); + document.dispatchEvent( new Event( 'pointerdown' ) ); + expect( made ).toHaveLength( 0 ); + + beeper.setEnabled( true ); + document.dispatchEvent( new Event( 'pointerdown' ) ); + expect( made ).toHaveLength( 1 ); + + beeper.setEnabled( false ); + expect( made[ 0 ].closed ).toBe( true ); + document.dispatchEvent( new Event( 'keydown' ) ); + expect( made ).toHaveLength( 1 ); + beeper.destroy(); + } finally { + ( window as unknown as { AudioContext: unknown } ).AudioContext = previous; + } + } ); +} ); + +describe( 'layout settings', () => { + test( 'the band layout and its height are sanitized', () => { + expect( sanitizePulseSettings( {} ).layout ).toBe( 'full' ); + expect( sanitizePulseSettings( { layout: 'band', bandHeight: 999 } ) ).toMatchObject( { layout: 'band', bandHeight: 300 } ); + expect( sanitizePulseSettings( { layout: 'sideways', bandHeight: 'tall' } ) ).toMatchObject( { layout: 'full', bandHeight: 88 } ); + } ); +} ); + +describe( 'diagnosis, itemised', () => { + test( 'each penalty says what it is about and what it costs', () => { + const h = new HealthMonitor(); + h.setServer( 0, { siteHealth: { good: 1, recommended: 2, critical: 1 }, requests: 0, failures: 0, cronLate: 4000 } ); + for ( let i = 0; i < 10; i++ ) { + h.recordRequest( i * 100, i % 2 ? 404 : 200 ); + } + const topics = h.read( 1500 ).penalties.map( ( p ) => p.topic ); + expect( topics ).toEqual( expect.arrayContaining( [ 'site-health-critical', 'site-health-recommended', 'cron', 'failures' ] ) ); + const points = h.read( 1500 ).penalties.map( ( p ) => p.points ); + expect( [ ...points ].sort( ( a, b ) => b - a ) ).toEqual( points ); + } ); +} ); + +describe( 'incidents', () => { + test( 'the log keeps the newest, newest first, and notifies', () => { + const log = new IncidentLog(); + let changes = 0; + const off = log.subscribe( () => changes++ ); + for ( let i = 0; i < INCIDENT_MAX + 5; i++ ) { + log.add( { source: 'requests', detail: String( i ), at: i } ); + } + const list = log.list(); + expect( list ).toHaveLength( INCIDENT_MAX ); + expect( list[ 0 ].detail ).toBe( String( INCIDENT_MAX + 4 ) ); + expect( changes ).toBe( INCIDENT_MAX + 5 ); + log.clear(); + expect( log.list() ).toEqual( [] ); + off(); + } ); + + describe( 'filed by the taps', () => { + beforeEach( () => installHooksStub() ); + afterEach( () => clearHooksStub() ); + + test( 'a failed request inside a window keeps its URL, method and window', () => { + const { hub, release } = acquirePulseHub(); + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, { windowId: 'edit-php', method: 'POST', url: '/wp-admin/admin-ajax.php', status: 500, duration: 80, failed: true } ); + doAction( HOOKS.IFRAME_NETWORK_COMPLETED, { windowId: 'edit-php', method: 'GET', url: '/ok', status: 200, duration: 80, failed: false } ); + doAction( HOOKS.IFRAME_ERROR, { windowId: 'edit-php', message: 'TypeError: x is undefined', filename: 'https://x.test/a.js', lineno: 12 } ); + expect( hub.incidents.list() ).toEqual( [ + expect.objectContaining( { source: 'errors', detail: 'TypeError: x is undefined', where: 'https://x.test/a.js:12', windowId: 'edit-php' } ), + expect.objectContaining( { source: 'requests', detail: '500', status: 500, method: 'POST', url: '/wp-admin/admin-ajax.php', windowId: 'edit-php' } ), + ] ); + release(); + } ); + } ); +} ); + +describe( 'the readout as a button', () => { + test( 'where it can be reached it opens the issues; on the wallpaper it is an image', () => { + const host = document.createElement( 'div' ); + let opened = 0; + const button = createReadout( host, 'band', () => opened++ ); + const root = host.querySelector( '.os-pulse-readout' ) as HTMLElement; + expect( root.getAttribute( 'role' ) ).toBe( 'button' ); + root.click(); + root.dispatchEvent( new KeyboardEvent( 'keydown', { key: 'Enter' } ) ); + expect( opened ).toBe( 2 ); + // Each number explains itself on hover. + expect( ( root.children[ 1 ] as HTMLElement ).title ).toMatch( /Median response time/ ); + button.destroy(); + + const still = document.createElement( 'div' ); + createReadout( still, 'wallpaper' ).destroy(); + createReadout( still, 'wallpaper' ); + expect( still.querySelector( '.os-pulse-readout' )?.getAttribute( 'role' ) ).toBe( 'img' ); + } ); +} ); + +describe( 'Issues view', () => { + beforeEach( () => installHooksStub() ); + afterEach( () => clearHooksStub() ); + + test( 'lists what holds the score down and every failure, live', async () => { + const { hub, release } = acquirePulseHub(); + hub.health.setServer( performance.now(), { siteHealth: { good: 1, recommended: 0, critical: 2 }, requests: 0, failures: 0, cronLate: 0 } ); + const body = document.createElement( 'div' ); + const teardown = renderIssues( body ); + expect( body.textContent ).toContain( '2 critical Site Health issues' ); + expect( body.textContent ).toContain( 'Open Site Health' ); + expect( body.textContent ).toContain( 'No problems since this page loaded.' ); + + hub.incidents.add( { source: 'requests', detail: '404', status: 404, method: 'GET', url: '/wp-json/shop/v1/cart' } ); + await new Promise( ( r ) => requestAnimationFrame( () => r( null ) ) ); + expect( body.textContent ).toContain( 'GET /wp-json/shop/v1/cart' ); + expect( body.textContent ).toContain( 'Recent problems (1)' ); + + teardown(); + expect( body.children ).toHaveLength( 0 ); + release(); + } ); +} ); + +describe( 'settings form', () => { + test( 'the widget’s form edits its own settings and has no wallpaper layout', () => { + const writes: Array< Record< string, unknown > > = []; + const wallpaper = document.createElement( 'div' ); + renderPulseConfig( wallpaper, { settings: {}, setSettings: ( p ) => writes.push( p ) } ); + expect( wallpaper.textContent ).toContain( 'Wallpaper layout' ); + + const widget = document.createElement( 'div' ); + renderPulseConfig( widget, { settings: {}, setSettings: ( p ) => writes.push( p ), face: 'widget' } ); + expect( widget.textContent ).not.toContain( 'Wallpaper layout' ); + expect( widget.textContent ).toContain( 'Monitor' ); + } ); +} ); diff --git a/tests/vitest/window-resize.test.ts b/tests/vitest/window-resize.test.ts index 21adf5e9..2f3f3f98 100644 --- a/tests/vitest/window-resize.test.ts +++ b/tests/vitest/window-resize.test.ts @@ -88,4 +88,13 @@ describe( 'computeResize', () => { expect( r.width % 50 ).toBe( 0 ); expect( r.height % 50 ).toBe( 0 ); } ); + + test( 'a reserved band moves the top limit: the north edge stops at it, bottom pinned', () => { + // A 160px band reserved along the top of the desktop area. + const r = computeResize( + 'ne', 0, -400, 40, 300, 800, 600, 320, 200, NO_SNAP, { x: 0, y: 160 }, + ); + expect( r.y ).toBe( 160 ); + expect( r.y + r.height ).toBe( 300 + 600 ); + } ); } ); diff --git a/tests/vitest/work-area.test.ts b/tests/vitest/work-area.test.ts index 8fb7bae9..7b15212f 100644 --- a/tests/vitest/work-area.test.ts +++ b/tests/vitest/work-area.test.ts @@ -25,9 +25,13 @@ import { } from '../../src/work-area/compute'; import { _resetWorkAreaForTests, + getReservedInsets, getWorkArea, installWorkArea, measureWorkArea, + placementRectOf, + reservedRectOf, + reserveWorkArea, subscribeWorkArea, WORK_AREA_CHANGED_EVENT, workAreaInsetsOf, @@ -388,6 +392,51 @@ describe( 'installWorkArea', () => { ctl.destroy(); } ); + test( 'a reserved band shrinks the work area, after the dock band on its edge', () => { + const ctl = installWorkArea( { shell, shellBody: body, area } ); + const seen: number[] = []; + const off = subscribeWorkArea( ( s ) => seen.push( s.insets.top ) ); + + const release = reserveWorkArea( 'test/ecg', { edge: 'top', size: 160 } ); + expect( getWorkArea().insets ).toEqual( { top: 160, right: 0, bottom: 84, left: 0 } ); + expect( getWorkArea().rect ).toEqual( { x: 0, y: 160, width: 1600, height: 656 } ); + expect( seen ).toEqual( [ 160 ] ); + // Where the band itself sits, in viewport px. + expect( reservedRectOf( 'test/ecg' ) ).toEqual( { x: 0, y: 32, width: 1600, height: 160 } ); + + // Re-reserving the same id resizes in place; a second band stacks inward. + reserveWorkArea( 'test/ecg', { edge: 'top', size: 120 } ); + const second = reserveWorkArea( 'test/other', { edge: 'top', size: 40 } ); + expect( getReservedInsets().top ).toBe( 160 ); + expect( reservedRectOf( 'test/ecg' ) ).toEqual( { x: 0, y: 32, width: 1600, height: 120 } ); + expect( reservedRectOf( 'test/other' ) ).toEqual( { x: 0, y: 152, width: 1600, height: 40 } ); + + second(); + release(); + release(); // twice is harmless + expect( getWorkArea().insets.top ).toBe( 0 ); + expect( reservedRectOf( 'test/ecg' ) ).toBeNull(); + off(); + ctl.destroy(); + } ); + + test( 'placement is bounded by reserved bands only, never by the dock band', () => { + const ctl = installWorkArea( { shell, shellBody: body, area } ); + // A window may be dragged under the dock: the whole area. + expect( placementRectOf( area ) ).toEqual( { x: 0, y: 0, width: 1600, height: 900 } ); + const release = reserveWorkArea( 'test/ecg', { edge: 'top', size: 160 } ); + expect( placementRectOf( area ) ).toEqual( { x: 0, y: 160, width: 1600, height: 740 } ); + release(); + ctl.destroy(); + } ); + + test( 'a malformed reservation is ignored', () => { + const release = reserveWorkArea( '', { edge: 'top', size: 100 } ); + reserveWorkArea( 'x', { edge: 'middle' as 'top', size: 100 } ); + expect( getReservedInsets() ).toEqual( { top: 0, right: 0, bottom: 0, left: 0 } ); + release(); + } ); + test( 'destroy stops listening', () => { const ctl = installWorkArea( { shell, shellBody: body, area } ); ctl.destroy(); diff --git a/vite.config.js b/vite.config.js index 33433e18..b75503f6 100644 --- a/vite.config.js +++ b/vite.config.js @@ -435,6 +435,18 @@ const TARGETS = { fileBase: 'snow-wallpaper', iifeName: 'openStationSnowWallpaper', }, + // Pulse Monitor — a PixiJS ECG trace that beats for every request + // and shell signal, with the rhythm doubling as a site-health + // diagnosis. ONE bundle for both faces: it publishes the canvas + // wallpaper (`window.openStationWallpapers['wp-pulse']`) and the + // widget (`window.openStationWidgets['desktop-mode/pulse']`), so + // the engine, the signal taps and the Heartbeat feed load once + // whichever face (or both) is on the desk. + 'pulse-monitor': { + entry: 'src/plugins/pulse-monitor/index.ts', + fileBase: 'pulse-monitor', + iifeName: 'openStationPulseMonitor', + }, // Mio — the desk companion: a PixiJS soft-body blob with a // chroma neon outline that floats over the wallpaper, falls onto // nearby windows, watches the pointer, and can be dragged around.