toga-ai 1.0.605 → 1.0.607

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: OneUptime Monitor Status Panel & Outage Alerting (tools /clickup/react)
2
+ title: OneUptime Monitor Status Panel & Outage Alerting (tools /developer)
3
3
  framework: "1.0"
4
4
  repo: tools
5
5
  project: Tools
@@ -10,7 +10,9 @@ updated: 2026-08-18
10
10
  owners: [kyalamarthi, jcardinal]
11
11
  files:
12
12
  - tools/assets/clickup/sprint-dashboard.html
13
- - tools/mvc/clickup/react/get.php
13
+ - tools/_/app/clickup/monitors.php
14
+ - tools/v2/monitors/status/index.php
15
+ - tools/mvc/developer/get.php
14
16
  related:
15
17
  - ./clickup-sprint-dashboard.md
16
18
  - ../architecture.md
@@ -20,273 +22,271 @@ related:
20
22
 
21
23
  ## Summary
22
24
 
23
- A live **operational monitor column** on the wall-display dashboard at `/clickup/react`. The
24
- page is a two-column split — the ClickUp sprint board at 80%, the monitor column filling the
25
- rest, each a bordered rounded card. The column shows an overall status pill, a **single solid
26
- status circle** carrying the whole system's state, a per-status legend (count + %), and a
27
- "Degraded and offline" card listing name / group / status / how long it has been in that state.
28
-
29
- The display is designed to be read **from across a room**: when a monitor changes to a bad state
30
- it **beeps, pops up and blinks**, and for as long as anything is still down the **screen edges
31
- hold a standing colour tint**.
32
-
33
- It reads the **OneUptime public status-page overview API** directly from the browser. This is
34
- the **read/consumer** side of OneUptime and is unrelated to the push-heartbeat monitors the
35
- worker tiers report *into* — see
25
+ A live **operational monitor column** on the wall-display dashboard at `/developer` (the route
26
+ was renamed from `/clickup/react` — the internal asset path `assets/clickup/sprint-dashboard.html`
27
+ and the `App_Clickup_*` class names are deliberately unchanged). The page is a two-column split —
28
+ the ClickUp sprint board at 75%, the monitor column filling the rest.
29
+
30
+ The panel is **state-adaptive: calm when healthy, loud when broken.**
31
+
32
+ - **Healthy** = one calm full-height green hero ("ALL SYSTEMS OPERATIONAL" + monitor count), a
33
+ soft glow on a dark panel — **not** a saturated fill. In the green state the hero is
34
+ **full-bleed flush** to the pane edges (no panel header, no inner padding, no hero border —
35
+ clipped by the pane's `overflow:hidden` + rounded corners).
36
+ - **Bad** = a saturated DEGRADED (amber) / OUTAGE (red) band plus the affected-monitor list as
37
+ cards that **grow to share the column height** (min-height floor, scroll when many). Each
38
+ affected card puts its icon centered **above** the text, larger, with a role-colored full
39
+ border; monitor names **wrap** (they used to truncate on the TV). Non-green states keep the
40
+ panel header + padded card layout.
41
+ - **Unknown** = a neutral grey "STATUS UNKNOWN — Monitor data unavailable" when there is no
42
+ usable data — this closes the old "0 monitors parsed looks like all-green" gap.
43
+
44
+ The display is read **from across a room**: a monitor changing to a bad state **beeps, pops up
45
+ and blinks**, and for as long as anything is down the **screen edges hold a standing colour
46
+ tint**.
47
+
48
+ The monitor data now comes from a **server-side, same-origin proxy** (`/v2/monitors/status/`,
49
+ `App_Clickup_Monitors`) — the browser no longer POSTs cross-origin to OneUptime. This is the
50
+ **read/consumer** side of OneUptime, unrelated to the push-heartbeat monitors the worker tiers
51
+ report *into* — see
36
52
  [OneUptime push-metric monitors for 2.0 workers](../../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md)
37
- and [OneUptime external uptime monitoring for 1.0 workers](../../worker/features/oneuptime-worker-uptime-monitoring.md)
38
- for that side. Those docs define what *creates* the statuses this panel renders.
53
+ and [OneUptime external uptime monitoring for 1.0 workers](../../worker/features/oneuptime-worker-uptime-monitoring.md).
54
+ Those docs define what *creates* the statuses this panel renders.
39
55
 
40
56
  ## How it works
41
57
 
42
58
  ### One document, not a nested iframe
43
59
 
44
- The panel is implemented as React components **in the same `sprint-dashboard.html` document**
45
- as the sprint board. It is deliberately **not** a nested iframe: `mvc/clickup/react/get.php`
46
- already iframes this page, so nesting a second frame would load a **second React runtime** and
47
- run a **duplicate ~250 KB poll**.
60
+ The panel is implemented as React components **in the same `sprint-dashboard.html` document** as
61
+ the sprint board (via CDN React, no build step). It is deliberately **not** a nested iframe:
62
+ `mvc/developer/get.php` already iframes this page, so a second frame would load a second React
63
+ runtime and a duplicate poll. The sprint board component is `SprintBoard`, wrapped by a
64
+ `Dashboard` that owns the monitor poll and passes it down as props, so the status panel and the
65
+ alert watcher share **one** fetch loop.
66
+
67
+ Only the monitor fetch runs at `MONITOR_REFRESH_MS`; sprint data stays on the page's 5-minute
68
+ `AUTO_REFRESH_MS`.
69
+
70
+ ### Data source — server-side proxy `/v2/monitors/status/` (`App_Clickup_Monitors`)
71
+
72
+ The client `useMonitors` hook now does a **same-origin GET** to `/v2/monitors/status/` and
73
+ unwraps `.data` from the api2 envelope — the SSO session cookie authenticates it like every other
74
+ `/v2/*` call. The old cross-origin browser POST to `oneuptime.com`, the client-side
75
+ `buildMonitorModel` / `monRole` / `monVal` parsers, and the hardcoded `MONITOR_STATUS_PAGE_ID`
76
+ were **removed from the HTML**.
48
77
 
49
- The existing sprint board was left byte-for-byte unchanged — its component was renamed
50
- `SprintBoard` and a two-line `Dashboard` wrapper added around it plus the monitor column. The
51
- monitor poll is lifted into `Dashboard` and passed **down as props**, so the status panel and
52
- the alert watcher share **one** fetch loop rather than polling twice.
78
+ `App_Clickup_Monitors::serve()` (`_/app/clickup/monitors.php`) mirrors `App_Clickup_Sprint`:
53
79
 
54
- Only the monitor fetch runs at `MONITOR_REFRESH_MS` (15s); sprint data stays on the page's
55
- existing 5-minute `AUTO_REFRESH_MS`. At 15s, outage detection (beep/popup/blink) reacts within
56
- ~15s, and the ~250 KB cross-origin poll costs ~1 MB/min per open tab.
80
+ - Same **SSO / persona JSON auth gate** (`App_Auth::isLoggedIn()` + persona check, JSON 401/403
81
+ on failure), same **api2 `{success, data, errors}` envelope**, uuid4 transaction id.
82
+ - Server-side **curl POST** to the OneUptime public status-page overview API, reducing the
83
+ **~250 KB** upstream payload to a **~1 KB** model:
84
+ `{total, buckets, rows[{key, name, group, statusName, role, since-epoch-ms}], overallName,
85
+ overallRole}`.
86
+ - Caches ~5s via **APCu**, so one upstream fetch is shared across all open display tabs and every
87
+ tab shows one consistent fresh snapshot.
88
+ - Status-page ID is a server constant `DEFAULT_STATUS_PAGE_ID`, overridable via
89
+ `[oneuptime] status_page_id` in config (location only — no value recorded here).
57
90
 
58
- ### Upstream contract — OneUptime public status-page overview API
91
+ This eliminated the app's **only** cross-origin browser dependency and its CORS / edge-cache /
92
+ mixed-context fragility (the direct browser poll was silently not re-polling), and cut traffic
93
+ from ~1 MB/min per tab to ~1 KB per poll.
59
94
 
60
- Reusable for any future status display. Endpoint:
95
+ ### Upstream contract — OneUptime public status-page overview API (now consumed server-side)
96
+
97
+ Reusable for any future status display; the parsing now lives in PHP, not the browser. Endpoint:
61
98
 
62
99
  ```
63
100
  POST https://oneuptime.com/status-page-api/overview/<statusPageId>
64
101
  body: {}
65
102
  ```
66
103
 
67
- - **It is a POST, not a GET**, and it returns **raw JSON** — **not** the api2
68
- `{isSuccess, data, messages}` envelope used everywhere else in TOGA. The payload **is** the
69
- data. Do not unwrap a `.data` that isn't there.
70
- - A **public** status page needs **no auth** and answers with `Access-Control-Allow-Origin: *`,
71
- so the browser can call it cross-origin with **no proxy and no credentials**. This is the one
72
- cross-origin call on a page whose every other fetch is same-origin `/v2/*`.
73
- - The response is **~250 KB**.
74
- - Values arrive as **typed envelopes** — `{_type: "ObjectID" | "DateTime" | "Color", value: …}`
75
- — so read `.value`, never the object. Dates are ISO-8601 UTC with a trailing `Z`.
104
+ - **It is a POST, not a GET**, and returns **raw JSON** — **not** the api2 envelope. The payload
105
+ **is** the data.
106
+ - Values arrive as **typed envelopes** — `{_type: "ObjectID" | "DateTime" | "Color", value: …}` —
107
+ so read `.value`. Dates are ISO-8601 UTC (`Z`).
76
108
  - A monitor's **current** state is `statusPageResources[].monitor.currentMonitorStatusId`,
77
- resolved against the `monitorStatuses[]` list.
78
- - In `monitorStatusTimelines[]`, the entry with **`endsAt: null`** is the **open** interval —
79
- i.e. when the current state began. That is what drives "in this state for X".
80
-
81
- The status page ID is currently a hardcoded constant (`MONITOR_STATUS_PAGE_ID`) in the HTML;
82
- moving it to config is part of the recommended next step below. (Identifier location only — no
83
- value recorded here.)
84
-
85
- ### Status bucketing is derived, never hardcoded
86
-
87
- Buckets are computed from the status list itself, not from a hardcoded set of status names:
88
-
89
- - `isOperationalState` → **good**
90
- - the **worst-priority non-operational** status → **offline / critical**
91
- - anything in between → **degraded**
92
-
93
- So a renamed status, or a newly added one, still lands in the right bucket without a code
94
- change.
95
-
96
- ### The status circle — the colour is the message
97
-
98
- `MonitorCircle` is **one filled circle** whose colour is the whole system's state: green
99
- all-clear, amber if anything is degraded, red if anything is offline, **red winning when both**.
100
- It draws **nothing inside it** — no total, no label.
101
-
102
- That is deliberate, and it replaced a `MonitorDonut` (a segmented ring showing per-status
103
- proportions with the total in the centre) which was **deleted**. From across a room the colour is
104
- what carries; the legend directly beneath still gives the exact per-status counts, so no
105
- information is lost. The SVG keeps an `aria-label` listing the counts, so screen readers still
106
- get the numbers that came off the face of the circle.
107
-
108
- The circle's colour comes from the **same `standingRole()`** that drives the screen-edge tint, so
109
- the circle and the edges can never disagree about severity.
109
+ resolved against `monitorStatuses[]`; the open timeline interval (`endsAt: null`) is when the
110
+ current state began ("in this state for X").
111
+ - **Discovery — the id-shape asymmetry.** In the overview payload a monitor status's own `_id` is
112
+ a **plain string**, but the resource's `currentMonitorStatusId` is a **typed envelope**
113
+ (`{_type:'ObjectID', value}`). Unwrap the resource id; the status `_id` is already plain. (This
114
+ corrected an earlier mis-diagnosis that the client parser was broken — it was fine; the point is
115
+ now moot since parsing is server-side.)
116
+
117
+ Status bucketing is **derived, not hardcoded**: `isOperationalState` → good; worst-priority
118
+ non-operational → offline/critical; anything between → degraded. A renamed or new status still
119
+ buckets correctly.
120
+
121
+ ### Panel presentation — state-adaptive (supersedes the solid status circle)
122
+
123
+ The old design (a solid `MonitorCircle`, a per-status legend with counts + percentages, and a
124
+ redundant status pill) was **removed**, and the panel header renamed **"Monitor status" →
125
+ "Status"**. The governing decision (developer's "Option 2"): **calm when healthy, loud when
126
+ broken.**
127
+
128
+ - **`healthy` now requires `total > 0 && rows.length === 0`.** A green hero can no longer show on
129
+ an empty/malformed response — that renders **Unknown** instead (see below).
130
+ - **Green** — a single calm full-height green hero (soft glow, dark panel — not a saturated fill),
131
+ full-bleed flush to the pane edges (no header, no inner padding, no hero border; clipped by the
132
+ pane's `overflow:hidden` + rounded corners).
133
+ - **Degraded / outage** — a saturated amber/red band + the affected-monitor cards, which grow to
134
+ share the column height (min-height floor, scroll when many). Icon centered above the text,
135
+ larger, role-colored full border, wrapping names. Each card uses a **dark, role-tinted
136
+ background with a soft radial glow** (amber for degraded, red for offline) rather than a
137
+ near-black fill, so a single card that grows to fill the column reads as amber/red — not a
138
+ black void — while the saturated icon + status text stay legible on the tint.
139
+
140
+ **Tuned for a dark wall TV.** The incident-card tints were brightened for a dark display (glow
141
+ ~0.34–0.38, lifted base fills), and the green "operational" hero and grey "Unknown" panel were
142
+ brightened to match. Card fonts + icon, and the green hero's check / title / subtitle, were
143
+ enlarged for across-the-room readability.
144
+
145
+ ### Unknown state — closes the false-green gap
146
+
147
+ An empty/malformed OneUptime response (`total === 0`) or a fetch error with **no prior good
148
+ model** now renders a neutral grey **"STATUS UNKNOWN — Monitor data unavailable"**, never the
149
+ green hero. The server proxy leaves `total: 0` as-is for the client to treat as Unknown. This is
150
+ the durable fix for the previously-documented "0 monitors parsed = reassuring all-green" known
151
+ issue: a total loss of visibility no longer clears a standing red screen to green, and the mass
152
+ row drop-out is no longer mistaken for a full recovery.
110
153
 
111
154
  ### Alerting — edge-triggered events over a level-triggered state
112
155
 
113
- **The distinction to carry to any future alerting surface.** Two separate mechanisms, and mixing
114
- them up is the bug:
156
+ Two separate mechanisms; mixing them up is the bug:
115
157
 
116
158
  | | Trigger | Drives | Why |
117
159
  |---|---|---|---|
118
- | **Event** | **Edge** — fires on the transition | beeps, popup, 5s blink | A level-triggered *event* re-alarms every 15s poll for a monitor that has been offline an hour, and the wall display becomes a siren nobody can silence. |
119
- | **State** | **Level** — follows current state | standing edge tint, status circle | An edge-triggered *state* would show a clean screen when you open the page during an existing outage. **The board must never look healthy while it isn't.** |
120
-
121
- **Event — `useMonitorAlerts`** compares each poll's non-operational rows against the previous
122
- poll's:
123
-
124
- - The **first successful poll is the baseline**, so opening the page mid-outage is **silent**
125
- (the standing tint, being level-triggered, still shows immediately).
126
- - A state that simply persists never re-alarms.
127
- - A full recovery to operational is **visually silent** (no popup, no tint) but now plays a
128
- distinct **recovery chime** — see the recovery event below.
129
- - It fires on **any change of non-operational state** — `r.role !== previous.get(r.key)`, where
130
- the row set only ever holds non-operational monitors, so good→degraded, good→offline,
131
- degraded→offline **and offline→degraded** all alert. An earlier version compared severity
132
- numerically (`SEVERITY[new] > SEVERITY[old]`), which silently suppressed **offline → degraded**
133
- because that is a severity *decrease*. That transition is news, so it is now announced and the
134
- now-unused `SEVERITY` map was deleted.
135
-
136
- The event presentation, held for `ALERT_HOLD_MS` (5s):
137
-
138
- - **Sound** — Web Audio beeps, synthesised at runtime, so there is **no audio asset to ship**.
139
- Each role's sound is defined in a single `ALERT_SOUND` table keyed by role, read by a
140
- generalised `playBeeps(role)` (signature unchanged: role → bool). Each entry carries a beep
141
- **count**, base **freq** (Hz), a per-beep **step** (Hz added to each successive beep — `0` =
142
- flat, positive = a rising chime), duration/gap/volume, and a **wave** shape. Current tuned
143
- values: **warning (degraded) = 3× square @ 720 Hz** (flat), **critical (offline) = 4× square
144
- @ 410 Hz** (flat), **recovered = 3× square rising chime 620 → 800 → 980 Hz** (freq 620,
145
- step 180). Distinct count, pitch and the rising vs. flat contour make each event
146
- recognisable across a room without looking. The three sounds were tuned by ear in a
147
- throwaway standalone "Monitor Sound Lab" browser page and the final numbers ported in — the
148
- lab is not part of the repo.
149
- - **Popup** — a centre overlay naming each affected monitor. **Bad-state events only** —
150
- recovery fires no popup.
151
- - **Blink** — the tint element gains `.is-blinking` for the 5s pulse, then the colour **holds**.
152
- **Bad-state events only** — recovery fires no blink and no tint.
153
-
154
- **Recovery event — audio-only, by design.** `useMonitorAlerts` also computes `recovered` = keys
155
- that were non-operational in the previous poll and have **dropped out of `model.rows`** this poll,
156
- and calls `playBeeps('recovered')` (the rising chime) for them. It deliberately fires **no popup
157
- and no screen tint**: the level-triggered standing tint already clears itself when a row leaves
158
- the set, and a green "all clear" popup/flash would fight the "the board only shouts when something
159
- is wrong" design. So recovery is silent **visually** but audible. Two edge rules: the **first
160
- poll is still the baseline** (opening the page mid-outage does not chime), and if a bad-state
161
- change and a recovery land in the **same 15s poll the bad-state alert wins** that tick, avoiding
162
- overlapping sounds (simultaneous transitions are rare).
160
+ | **Event** | **Edge** — on the transition | beeps, popup, blink | A level-triggered event re-alarms every poll for a monitor down an hour → an unsilenceable siren. |
161
+ | **State** | **Level** — current state | standing edge tint | An edge-triggered state would show a clean screen when you open the page mid-outage. **The board must never look healthy while it isn't.** |
162
+
163
+ **Alert hold — popup and blink both 15s.** `useMonitorAlerts` returns `{alert, blink}` on
164
+ separate timers, but both now hold for **15000ms**: the centre popup (`ALERT_POPUP_MS`) and the
165
+ screen-edge blink (`ALERT_BLINK_MS`) each stay up a full 15s so a fresh alert is readable and
166
+ visible across the room. (`prefers-reduced-motion` still kills the fast edge pulse — see Gotchas.)
167
+ Previously both shared one 5s `ALERT_HOLD_MS`.
168
+
169
+ **Event — `useMonitorAlerts`** compares each poll's non-operational rows against the previous:
170
+
171
+ - The **first successful poll is the baseline**, so opening the page mid-outage is silent (the
172
+ standing tint still shows immediately, being level-triggered).
173
+ - Fires on **any change of non-operational state** (`r.role !== previous.get(r.key)`), so
174
+ good→degraded, good→offline, degraded→offline **and offline→degraded** all alert.
175
+ - It remembers each monitor's **full previous-poll row** (key→row), so recovered monitors can be
176
+ named by the recovery dialog below.
177
+ - **Sound** — Web Audio beeps synthesised at runtime (no audio asset). A per-role `ALERT_SOUND`
178
+ table (count / freq / per-beep `step` / duration / gap / volume / `wave`) read by
179
+ `playBeeps(role)`. Current tuning: warning 3× square @ 720 Hz (flat), critical 4× square @
180
+ 410 Hz (flat), recovered 3× square rising chime 620 → 800 → 980 Hz (freq 620, step 180).
181
+ - **Popup + blink** on **bad-state** events.
182
+
183
+ **Recovery event — dialog + chime, but no edge flash.** Keys that were non-operational last poll
184
+ and dropped out of `model.rows` this poll play the rising recovery chime **and** now show the
185
+ **centre dialog for 15s**: a green, check-icon "N monitor(s) OPERATIONAL" / "Back to operational"
186
+ that lists each recovered monitor as "Operational" (named from the remembered previous-poll rows).
187
+ The recovery branch sets a `role:'good'` alert (`AlertOverlay` has an `alert.role === 'good'`
188
+ variant) but deliberately calls **no `setBlink`** — recovery fires **no screen-edge flash/tint**,
189
+ because a green all-clear must not strobe the room and the level-triggered standing tint already
190
+ self-clears when a row leaves the set. First poll is still the baseline; if a bad-state change and
191
+ a recovery land in the same poll, the bad-state alert wins that tick.
163
192
 
164
193
  **State — `standingRole(model)`** returns `critical` if any row is offline, else `warning` if any
165
- is degraded, else `null` (no tint). The tint element only renders while `standingRole` is
166
- non-null, so a full recovery clears it.
194
+ is degraded, else `null`. The tint element renders only while `standingRole` is non-null, so full
195
+ recovery clears it. The tint (worst-current) and the popup (the event) can legitimately differ:
196
+ a degraded-event popup can sit on a red screen because something else is still offline — correct,
197
+ not a bug.
167
198
 
168
- Note the two can legitimately show different colours at once: the tint always reflects
169
- **worst-current** state while the popup reflects **the event**, so a degraded-event popup can sit
170
- on a red screen because something else is still offline. That is correct, not a bug.
199
+ ### Two colour pairs: meaning vs. legibility
171
200
 
172
- Verified across full cycles (against the original 1-beep-720 / 2-beep-990 tuning; the current
173
- values are 3×720 / 4×410 / recovered 620→800→980): distinct sounds per event, silent on
174
- unchanged, recovery now chimes but stays visually silent, popup held 4982 ms and 4955 ms against
175
- the 5s spec; amber standing → red when offline
176
- joined (priority) → amber when offline cleared → no tint on full recovery, with the blink
177
- stopping after 5s and the colour held. Circle verified against the DOM: `#0ca30c` all-clear,
178
- `#fab219` degraded only, `#d03b3b` with 1 degraded + 1 offline, and `textNodesInSvg = 0`.
201
+ `MON_COLOR` is the **validated status palette** and drives everything that **carries meaning**.
202
+ `MON_COLOR_BRIGHT` (`good:#25d825, warning:#ffd633, critical:#ff4d4d`) is used **only** for the
203
+ full-screen edge rim, where the only job is legibility from across a room. Do not let the bright
204
+ pair leak into anything semantic.
179
205
 
180
- ### Two colour pairs: meaning vs. legibility
206
+ Both reach the overlay as CSS custom properties (`--alert-color`, `--alert-bright`). The rim is a
207
+ three-layer `box-shadow` (solid hot rim + tight bright bloom + a deep haze that stays on the base
208
+ hue so the dashboard stays readable), each bright layer written
209
+ `var(--alert-bright, var(--alert-color))` so it degrades to the base colour.
210
+
211
+ Standing and blinking differ in intensity **and tempo**, keeping "still broken" distinct from
212
+ "something just changed":
213
+
214
+ - `.alert-flash` (standing) — 12px rim, **slowly breathes** opacity 0→1→0 on a **4-second**
215
+ ease-in-out loop (`@keyframes alert-breathe`, 2s fade-in / 2s fade-out).
216
+ - `.alert-flash.is-blinking` (event) — 20px rim, `opacity: 1`, plus the fast **0.7s** pulse. Its
217
+ compound selector (0,2,0) out-specifies the standing breathe (0,1,0).
181
218
 
182
- `MON_COLOR` is the **validated status palette** and drives everything that **carries meaning** —
183
- icons, legend, popup border, status text, and the status circle. `MON_COLOR_BRIGHT`
184
- (`good:#25d825, warning:#ffd633, critical:#ff4d4d`) is used **only** for the full-screen edge rim,
185
- where nothing depends on its exact value and the only job is legibility from across a room. Do
186
- not let the bright pair leak into anything semantic.
187
-
188
- Both reach the overlay as CSS custom properties — `--alert-color` and `--alert-bright`. The rim is
189
- a **three-layer `box-shadow`**: solid hot rim + tight bright bloom + a deep haze that deliberately
190
- stays on the **base** hue, so the dashboard underneath stays readable instead of washing out. Each
191
- bright layer is written `var(--alert-bright, var(--alert-color))` so it **degrades to the base
192
- colour** if the property never reaches the element.
193
-
194
- Standing and blinking differ in intensity **and in animation tempo**, which is what keeps
195
- "still broken" distinguishable from "something just changed":
196
-
197
- - `.alert-flash` (standing) — 12px rim, **slowly breathes** its opacity 0 → 1 → 0 on a
198
- **4-second ease-in-out loop** (`@keyframes alert-breathe { 0%,100% { opacity: 0 } 50% {
199
- opacity: 1 } }`, applied `animation: alert-breathe 4s ease-in-out infinite` — a 4s cycle =
200
- 2s fade-in / 2s fade-out). It may sit on screen for hours, so the gentle breathe draws a
201
- distant eye without reading as urgent. The tempo was tuned with the developer.
202
- - `.alert-flash.is-blinking` (event) — 20px rim, `opacity: 1`, plus the fast **0.7s** pulse.
203
- Its compound selector (0,2,0) out-specifies the standing breathe (0,1,0), so a just-changed
204
- monitor shows the urgent fast blink, not the slow breathe.
205
-
206
- The base `.alert-flash { opacity: .85 }` is the **reduced-motion fallback**, not the animated
207
- default — see the `prefers-reduced-motion` gotcha. It is deliberately non-zero so a
208
- reduced-motion viewer never lands on a fully-invisible (opacity 0) glow.
219
+ The base `.alert-flash { opacity: .85 }` is the **reduced-motion fallback**, deliberately non-zero
220
+ so a reduced-motion viewer never lands on a fully-invisible glow.
209
221
 
210
222
  ## Gotchas
211
223
 
224
+ - **Never call `curl_close()` in App_ code — the strict handler turns its PHP 8.5 deprecation into
225
+ a fatal (prod incident CV-2).** `App_Clickup_Monitors` originally called `curl_close($ch)`. On
226
+ the production Elastic Beanstalk PHP (8.5) `curl_close()` is **deprecated**, and the App_
227
+ framework's strict error handler promotes that `E_DEPRECATED` to a **fatal**, so
228
+ `/v2/monitors/status/` 500'd with an HTML error page (Sentry id CV-2). Fix: remove the call
229
+ entirely — `curl_close()` has been a **no-op since PHP 8.0** (the handle is a GC-freed
230
+ `CurlHandle`); use `unset($ch)` if you want to drop the reference. Same fatal-from-strict-handler
231
+ family as the E_ALL / `buildArrayOfRows`-by-ref gotcha. The endpoint's final catch was also
232
+ broadened from `catch (Exception)` to **`catch (\Throwable)`** so any catchable `Error` degrades
233
+ to the JSON "Status unknown" envelope instead of a 500 page.
234
+ - **`crypto.randomUUID()` is undefined on a non-secure origin — it crashed React to a black
235
+ screen.** The page sets a per-request `transactionId` header using `crypto.randomUUID()`, which
236
+ exists **only in a secure context** (https, or http on `localhost`/`127.0.0.1`). Served over
237
+ plain HTTP by hostname / LAN IP (e.g. `http://tools/…`, "Not secure"), it is `undefined` and
238
+ throws **before React mounts** → a black screen. Fixed with a `transactionId()` helper:
239
+ `crypto.randomUUID` if present → else `crypto.getRandomValues` (a real v4) → else a non-crypto
240
+ id. **Reusable for any browser code that must run over a non-secure origin** — never call
241
+ `crypto.randomUUID`/`crypto.subtle` unguarded.
212
242
  - **The embedding iframe needs `allow="autoplay"` or the alert sound is silently blocked.**
213
- Browser autoplay policy is scoped **per-iframe**. `mvc/clickup/react/get.php` embedded the
214
- dashboard with `allow="fullscreen"`, so Web Audio inside the frame was policy-blocked and the
215
- beeps would never have sounded in the Tools app — with no error. It is now
216
- `allow="fullscreen; autoplay"`. Any future iframed page that makes sound needs the same.
243
+ Browser autoplay policy is scoped **per-iframe**. `mvc/developer/get.php` embeds the dashboard
244
+ with `allow="fullscreen; autoplay"`. Any future iframed page that makes sound needs the same.
217
245
  - **Permission is not enough — browsers still require one user gesture per session.** The page
218
- unlocks the `AudioContext` on the first click/keypress, and if an alert fires while audio is
219
- still blocked the popup **says so** rather than failing silently. On an **unattended wall
220
- display nobody ever clicks**, so alerts stay visual-only. For kiosk use, launch Chrome with
246
+ unlocks the `AudioContext` on the first click/keypress; if an alert fires while audio is still
247
+ blocked the popup says so rather than failing silently. On an **unattended wall display nobody
248
+ clicks**, so alerts stay visual-only. For kiosk use launch Chrome with
221
249
  `--autoplay-policy=no-user-gesture-required`.
222
- - **The `prefers-reduced-motion` override needs the compound selector.** Blinking is a
223
- vestibular risk, so the reduced-motion rule kills the animation and holds the colour steady.
224
- Once the blink moved to `.alert-flash.is-blinking` (specificity 0,2,0), a reduced-motion rule
225
- on `.alert-flash` alone (0,1,0) **could no longer override it** and the screen kept pulsing.
226
- The rule must list both: `.alert-flash, .alert-flash.is-blinking { animation: none; }`. Any
227
- future state/modifier CSS split has the same trap. Because it already names `.alert-flash`,
228
- this same rule **also covers the new slow `alert-breathe` breathe** — a reduced-motion viewer
229
- falls back to the static `opacity: .85` glow with **no separate rule needed**.
230
- - **A parse failure currently looks calmer than an outage** (known issue, not fixed). If the
231
- OneUptime call returns HTTP 200 with a malformed or empty body, `buildMonitorModel` yields
232
- total 0, empty buckets and empty rows. The status pill correctly degrades to "Unknown", but
233
- the affected-list card still reads **"All 0 monitors are operational"**, and any
234
- previously-degraded monitor silently disappears — and, worse now that recovery is audible,
235
- the mass drop-out is **indistinguishable from a full recovery**, so the panel plays the happy
236
- **recovery chime** for every vanished monitor on a parse failure. Since the standing
237
- tint and the status circle are both driven by that same empty row set, a parse failure also
238
- **clears a standing red screen back to green** — the most reassuring possible display of a
239
- total loss of visibility. Recommended fix: treat "zero resources parsed" as an **error
240
- state**, not a healthy one.
241
-
242
- ## Recommended next step (advisory — not built)
243
-
244
- Replace the direct browser → OneUptime poll with a small **server-side proxy endpoint** in the
245
- Tools app (e.g. `/v2/monitors/status/`) that fetches upstream, reduces the payload to the
246
- summary, and caches ~5s. Rationale:
247
-
248
- - The browser currently pulls **~250 KB every 15s (~1 MB/min per open tab)** to display about
249
- six numbers; a proxy makes it ~1 KB.
250
- - One upstream fetch is shared across **all** open displays instead of one per tab.
251
- - It removes the cross-origin dependency, and therefore the CORS/autoplay coupling above.
252
- - The status page ID moves into **config** instead of hardcoded HTML.
253
- - It gives a natural home for **server-side transition history**, which is what edge-triggered
254
- alerting really wants (today the baseline resets every time a tab reloads).
250
+ - **The `prefers-reduced-motion` override needs the compound selector.** Once the blink moved to
251
+ `.alert-flash.is-blinking` (0,2,0), a reduced-motion rule on `.alert-flash` alone (0,1,0) could
252
+ no longer override it. The rule must list both:
253
+ `.alert-flash, .alert-flash.is-blinking { animation: none; }`. Because it already names
254
+ `.alert-flash`, this same rule also covers the slow `alert-breathe` breathe.
255
255
 
256
256
  ## Change history
257
- - 2026-08-18 — Wall-display cadence tuning. The standing edge-tint breathe cycle changed from
258
- 5s to **4s** (`animation: alert-breathe 4s …` — 2s fade-in / 2s fade-out), and
259
- `MONITOR_REFRESH_MS` changed from 5s to **15s**, so outage detection now reacts within ~15s
260
- (was ~5s) and the ~250 KB cross-origin poll drops from ~3 MB/min to ~1 MB/min per open tab.
261
- Also corrects prior doc drift: the breathe cycle had been documented as 7s but the code was
262
- actually 5s. (jcardinal)
263
- - 2026-08-14 — Generalised alert-sound synthesis and added a **recovery chime**. The old
264
- `BEEP_COUNT`/`BEEP_HZ`/`BEEP_MS`/`BEEP_GAP_MS`/`BEEP_VOLUME` constants and the square-wave-only
265
- `playBeeps` were replaced by a single per-role `ALERT_SOUND` table (count / freq / per-beep
266
- `step` / duration / gap / volume / `wave`) read by a generalised `playBeeps(role)` (signature
267
- unchanged). Retuned to degraded 3×square@720, offline 4×square@410 (was degraded 1×@720,
268
- offline 2×@990). Recovery — a monitor dropping out of the non-operational row set — now plays
269
- a distinct rising chime (620→800→980, freq 620/step 180) and is **audio-only by design**: no
270
- popup, no tint (the standing tint already self-clears; a green "all clear" flash would fight
271
- the "only shouts when wrong" design). First poll stays the silent baseline; a bad-state change
272
- and a recovery in the same tick let the bad-state alert win. Sounds were tuned by ear in a
273
- throwaway standalone browser "Sound Lab" (not a repo file) and ported in. Corrects the prior
274
- "recovery is silent" and old beep-count/pitch documentation. (jcardinal)
275
- - 2026-08-14 — The standing (level-triggered) edge tint now **slowly breathes** its opacity
276
- 0→1→0 on a 7s ease-in-out loop (`@keyframes alert-breathe`) instead of holding a static tint,
277
- so a still-broken screen draws a distant eye. The fast 0.7s `.is-blinking` event pulse still
278
- out-specifies it, keeping "just changed" (fast) distinct from "still broken" (slow breathe);
279
- tempo tuned with the developer (0.5→0.92/10s too subtle, 0→1/10s too slow → 0→1/7s). The
280
- existing `prefers-reduced-motion` compound-selector rule already covers the new animation, so
281
- reduced-motion falls back to the deliberately non-zero static `opacity: .85` glow. (jcardinal)
282
- - 2026-08-14 — Built the monitor status column on `/clickup/react` (split layout, solid status
283
- circle + legend + degraded/offline card) as components in the same document rather than a
284
- nested iframe, sharing one 5s poll; documented the OneUptime public status-page overview API
285
- contract (POST, raw JSON not the api2 envelope, CORS-open, typed `{_type,value}` envelopes,
286
- `endsAt: null` = open interval) and derived status bucketing. Alerting splits **edge-triggered
287
- events** (beeps / popup / 5s blink, now firing on any non-operational change incl.
288
- offline → degraded) from a **level-triggered standing tint** driven by `standingRole()`, which
289
- also colours the circle so the two can never disagree; added the `MON_COLOR_BRIGHT` rim-only
290
- palette. Fixed the iframe `allow="fullscreen; autoplay"` autoplay block and recorded the
291
- user-gesture/kiosk caveat, the `prefers-reduced-motion` specificity trap, the malformed-body
292
- "all 0 monitors operational" gap, and the server-side-proxy recommendation. (kyalamarthi)
257
+ - 2026-08-18 — **State-adaptive redesign + server-side proxy + Unknown state + /developer rename.**
258
+ Replaced the solid `MonitorCircle` / legend / percentages / status pill with a calm full-height
259
+ green hero (full-bleed flush in the green state) vs. a saturated amber/red band with growing,
260
+ wrapping affected-monitor cards on a dark, role-tinted radial-glow background (brightened for a
261
+ dark TV, larger type); renamed the panel header to **"Status"** and widened the monitor column
262
+ (sprint board 80%→75%). Added a neutral grey **Unknown** state (`healthy` now requires
263
+ `total > 0 && rows.length === 0`), closing the old "0 parsed = all-green" gap. Both the centre
264
+ popup (`ALERT_POPUP_MS`) and the screen-edge blink (`ALERT_BLINK_MS`) now hold **15s**
265
+ (`useMonitorAlerts` returns `{alert, blink}` on separate timers). **Recovery** now shows a green
266
+ check-icon dialog for 15s naming the recovered monitors (a `role:'good'` `AlertOverlay` variant,
267
+ driven by remembered previous-poll rows) in addition to the rising chime, but still fires **no
268
+ edge flash/tint**. Monitor data now comes from a **server-side same-origin proxy**
269
+ `/v2/monitors/status/` (`App_Clickup_Monitors`, APCu ~5s cache, ~250 KB→~1 KB, status-page ID in
270
+ config), removing the client's cross-origin OneUptime POST and its parsers; recorded the
271
+ OneUptime id-shape asymmetry (resource `currentMonitorStatusId` is a typed
272
+ `{_type:'ObjectID',value}` envelope, the status `_id` is plain). Fixed prod incident **CV-2**:
273
+ removed `curl_close()` (deprecated in PHP 8.5 → fatal under the App_ strict handler) and
274
+ broadened the endpoint catch to `\Throwable`. Route renamed `/clickup/react` → `/developer`.
275
+ Added the `crypto.randomUUID()` non-secure-origin black-screen gotcha. (jcardinal)
276
+ - 2026-08-18 — Wall-display cadence tuning. Standing edge-tint breathe cycle 5s→**4s**;
277
+ `MONITOR_REFRESH_MS` 5s→**15s** (outage detection now reacts within ~15s). Corrected prior doc
278
+ drift (breathe had been documented as 7s but code was 5s). (jcardinal)
279
+ - 2026-08-14 — Generalised alert-sound synthesis and added a **recovery chime** (per-role
280
+ `ALERT_SOUND` table, retuned degraded 3×720 / offline 4×410 / recovered rising 620→800→980);
281
+ recovery is audio-only by design (no popup/tint). First poll stays the silent baseline;
282
+ bad-state change wins over a same-tick recovery. (jcardinal)
283
+ - 2026-08-14 — Standing edge tint slowly breathes opacity 0→1→0 (`@keyframes alert-breathe`);
284
+ fast 0.7s `.is-blinking` event pulse out-specifies it; reduced-motion falls back to static
285
+ `opacity: .85`. (jcardinal)
286
+ - 2026-08-14 — Built the monitor status column (split layout, solid status circle + legend +
287
+ degraded/offline card) as components in the same document sharing one poll; documented the
288
+ OneUptime public status-page overview API contract and edge-vs-level alerting; added the
289
+ `MON_COLOR_BRIGHT` rim palette and the iframe autoplay / user-gesture / reduced-motion gotchas.
290
+ (kyalamarthi)
291
+ </content>
292
+ </invoke>
@@ -17,7 +17,7 @@
17
17
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Api/Oneuptime/Oneuptime.php, api2/Component/Api/Netsuite/Netsuite.php, _underscore/Component/Api/Paypal/Paypal.php |
18
18
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('<property>')` reads a value out of the active `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.** | _underscore/Config.php, _underscore/Component/Api/Paypal/Paypal.php, _underscore/Worker.php, api2/Config/beta.ini, api2/Config/sandbox-dev.ini |
19
19
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
20
- | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php |
20
+ | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php, _underscore/Model/Client/Logs/Email.php, _underscore/Model/Client/Logs/EmailAttachment.php |
21
21
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
22
22
  | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
23
23
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |