release: promote staging to main - #714
Conversation
…kens Adopt the shared --duration-*/--ease-smooth/--expo-out tokens across the uptime heatmap strip, latency chart, and theme toggle so collapse, hover, and tooltip timing match the rest of the design system. - Raise TOOLTIP_HIDE_MS to 150 so the heatmap tooltip is not unmounted 30ms before its fade-out completes - Reposition the tooltip via translate3d instead of transitioning left/top so it tracks the highlight overlay on the compositor at a matching speed - Skip the latency chart expand animation until the user actually toggles, so a localStorage-restored open state does not pop open after hydration - Respect prefers-reduced-motion in the chart, which CSS cannot reach - Disable the Recharts path draw-in that played at an unrelated tempo inside the 200ms panel expansion - Give the theme toggle icons a scale transition instead of a hard flip - Fade in the cells-variant heatmap tooltip for parity with segments
Status page surfaces used ad-hoc durations and curves while the rest of the design system was already on the shared motion tokens, so hover, caret, and panel timing all ran at different speeds. - Move card hover, caret, navbar, and footer link transitions onto --duration-quick/--duration-base with --ease-smooth/--expo-out - Keep the monitor panel border mounted and fade its color so the 1px divider no longer pops in at the edge of the expand animation - Add !important to the prefers-reduced-motion guard, which lost to every Tailwind utility because a bare * selector has zero specificity - Rebuild the loading skeleton to match the real layout: h-14 navbar with the inset divider, no phantom hero block, matching spacing and paddings
The universal selector in the prefers-reduced-motion block has zero specificity, so every Tailwind transition and animation utility overrode it and the guard was inert.
Autumn only camelCases top-level customer keys; payment_method is passed through as the raw Stripe object, so card.expMonth/expYear were always undefined and the "00" fallbacks rendered every card as 00/00.
…cross read failures Current monitor state (status + failure_streak) moved from a per-check ClickHouse point lookup to Redis, keyed by site with a 30-day TTL. ClickHouse remains the cold-start fallback and its query is now bounded to 30 days to match. The lookup returns found/missing/unavailable instead of an optional value. Previously a transient ClickHouse read error and a genuinely absent monitor both collapsed to undefined, so resolveFailureStreak took its reset branch and restarted an in-progress streak at 1 - a monitor down for days would report as freshly down and reset any threshold-based escalation. The point lookup ran 1901 times and read 120M rows per hour, 23% of all cluster queries, to return a single row each time.
Three independent paths let the headline contradict reality, and the sitemap advertised every status page ever created. - Query unresolved incidents with no date floor and merge them with the 90-day window, so an incident opened 95 days ago (or pushed out by the 50-row limit) no longer vanishes and leaves the header "Operational" - Derive incident and monitor status independently and take the more severe of the two; an open minor incident no longer masks every monitor being down - Only stamp resolvedAt on the transition into resolved, so posting a follow-up update to a resolved incident stops resurrecting it as active - Restrict the sitemap to pages with at least one unpaused monitor and mark monitorless pages noindex, keeping scratch slugs out of the index - Stop disallowing /_next/ in robots.txt, which blocked Googlebot from the CSS and JS it needs to render the page
ClickHouse buckets uptime rows by UTC calendar day, but the heatmap generated its cells from the browser's local day and then looked them up by UTC key. West of UTC the current day had no cell at all, so an active outage went unrendered and the oldest cell stayed permanently gray; east of UTC the cell under the "Today" label was always empty. Build the day list in UTC and format the day labels in UTC to match, including the latency chart axis and tooltip, which were labelling every point one day early west of UTC. Adds formatUtcDateOnly so the dashboard heatmap callers label their days from the same clock.
…orts The public status page is hit hardest during outages, so first-load weight matters most exactly when it is worst. - Swap the ten OTF faces for the WOFF2 versions already vendored in the docs app, same weights and no visual change: 1.2 MB down to 144 KB - Give the error shell a local three-icon module so error.tsx no longer forms a second client entry group that emitted the whole 186-icon Nucleo barrel a second time - Add a ./client/* subpath export and deep-import Avatar and ThemeToggle in the navbar instead of pulling the full client barrel, which dropped react-day-picker, cmdk, input-otp, and motion from the page bundle
The 4748-line nucleo.tsx becomes 186 per-icon modules plus a shared nucleo-icon.tsx holding the NucleoIcon base component and NucleoIconProps, which are now exported for sibling modules but deliberately kept out of the barrel so the public API is unchanged. icons/index.ts re-exports all 186, so @databuddy/ui/icons resolves exactly as before. Verified the export set reachable through the barrel is identical to the previous file, name for name. Split with a scripted codemod that asserted every declared export was parsed, no content fell between blocks, and no two icons collided on a filename. The three internal modules that imported ./icons/nucleo directly now import the barrel.
nucleo.tsx was a single 4,748-line module holding 186 icons, so bundlers could not tree-shake it: any app importing one icon shipped all of them, and the status app emitted the whole barrel twice because its error boundary formed a second client entry group. Each icon now lives in its own module behind the same @databuddy/ui/icons entry point, so the public API is unchanged. Every SVG path and attribute is byte-identical to the previous barrel, so nothing renders differently. Drop forwardRef in favour of the React 19 ref-as-prop form and widen the one consumer that typed icons as ForwardRefExoticComponent to ComponentType, which no longer requires a forwardRef implementation. Status app: no chunk now carries more than 17 icon paths, against 543 in a single 228 KB chunk before.
The biome ignore glob was "!**/ui/**/*.tsx", which matches any path with a ui segment, so it silently excluded all of packages/ui rather than just generated components/ui folders: 311 files, the entire shared design system, were never linted or format-checked, and the pre-commit hook skipped them. Narrow the glob to **/components/ui/**/*.tsx and format the files that were already drifting. Icons opt out of noSvgWithoutTitle, since the base primitive renders a title only when one is supplied and is aria-hidden otherwise.
The error shell inlined three icons to stop error.tsx pulling the whole 186-icon barrel into a second client entry group. The barrel now tree-shakes, so the workaround is dead weight and the shell can import from @databuddy/ui/icons again.
Every latency chart renders collapsed by default, but the panel was mounted unconditionally and isOpen only toggled a CSS collapse, so the dynamic import fired on hydration and pulled recharts on essentially every page load. Split the recharts body into its own module behind React.lazy and mount it only once the section has been opened, leaving the header, summary metrics, and toggle in the main bundle. Replace the motion-driven expansion with the same grid-template-rows collapse the monitor card uses, which drops motion from the page and makes the two collapses run at one speed. Status app: recharts is now absent from first load entirely, moving ~141 KB gzip off page load for ~6 KB added to the initial bundle. The chart chunk costs 97 KB gzip on first expand.
Reverts 43e15e9 in full. Puts apps/uptime/scripts/smoke.ts back as the uptime image's own smoke script and drops the shared scripts/smoke-health.ts, removes the smoke stage that commit added to links.Dockerfile, and undoes the lockfile pinning across the eight Dockerfiles.
Removes the smoke build stage and its script entirely. The final stage now copies the compiled binary straight from builder.
The Nucleo icon primitive accepts a weight prop and discards it, so every weight="duotone" / "bold" / "fill" was inert. Removes them across the dashboard and converts the status colours on the touched lines to the semantic success/warning/destructive tokens the rest of the app uses.
The Nucleo icon primitive accepts a weight prop and discards it, so these were inert.
NucleoIcon destructured weight only to discard it, so the prop was dead API that call sites were still passing. Drops it from the type and the primitive now that no caller sets it, and removes the per-icon displayName assignments, which only existed to name components that are already named by their export.
Last caller of the icon weight prop, which the primitive discarded.
SettingCard's expandable header was a div with onClick and a hand-rolled Enter handler: not focusable, no aria-expanded, and Space did nothing. The title block is now a button, which brings native focus and key handling and deletes the manual keydown. It wraps only the heading, since the row's children slot takes arbitrary interactive content. Input drops forwardRef for the React 19 ref-as-prop form, typed as Ref<HTMLInputElement> rather than RefObject so react-hook-form's callback refs from register() still type-check. Dialog's form-level Cmd+Enter handler is suppressed with a reason: the form is the correct scope for a submit shortcut.
There was a problem hiding this comment.
0 issues found across 4 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 18 unresolved issues from previous reviews.
Re-trigger cubic
REDPANDA_SSL dates from a self-hosted Hetzner broker with a plaintext SASL listener on 19092. Enabling TLS by default broke ingestion against it on 2026-05-12 and was reverted, then reintroduced as an opt-in when the move to Redpanda Cloud made SASL_SSL mandatory. Production now points at cloud.redpanda.com:9092, the selfhost compose wires no Redpanda at all, and SCRAM without TLS puts the broker credentials on the wire in cleartext. One correct value, so the flag is gone.
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
boolean.ts was six lines with its own test file and its own export path. The function still has seventeen importers and exists to stop hand-rolled process.env comparisons, which is what silently disabled Redpanda TLS, so it moves into app.ts rather than being inlined. Two files and one export entry gone.
There was a problem hiding this comment.
0 issues found across 22 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 19 unresolved issues from previous reviews.
Re-trigger cubic
getErrorLogFields started its search at error.cause, so a driver error passed directly lost error_pg_code and every diagnostic field; it now starts at the error itself, with a test for the unwrapped shape. parseRetryAfter dropped a valid Retry-After: 0 and fell back to the five second default. @databuddy/ui advertised react >=18 while its form primitives rely on React 19 ref-as-prop, and every consumer is on 19, so the range is corrected rather than restoring forwardRef. Dropped a vacuous expect(observed).toBe(true) whose value was always true.
There was a problem hiding this comment.
0 issues found across 5 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 18 unresolved issues from previous reviews.
Re-trigger cubic
markOversizedErrorsBody read Number(content-length), and Number(null) is 0, so a request that never declared a length skipped the 128KB cap entirely. Every existing test set the header explicitly, which is why it survived. It now measures the body when the length is absent, and a test sends an oversized payload with the header deleted. getFlag and revalidate wrote their result into the cache without checking they still owned the entry, so a request that settled late could overwrite a newer one; all three writes now compare the stored promise first, matching the check the context-changed branch already made.
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…e edit sheet The monitors and status pages lists registered TopBar.Actions without TopBar.Title, leaving the top bar's title slot empty and the pages without a heading, unlike websites and feedback. The edit and transfer buttons on a status page hide their labels below sm and had no accessible name. StatusPageSheet stays mounted between opens, so react-hook-form kept the values from a cancelled edit; it now resets from the current status page each time it opens. One raw bg-red-500/10 paired with text-destructive became bg-destructive/10.
There was a problem hiding this comment.
1 existing issue remains and no new issues found across 5 files (changes from recent commits).
Confidence score: 3/5
- In
apps/dashboard/components/status-pages/status-page-sheet.tsx, refreshing the status-page query while the sheet is open can reset the form and discard unsaved user edits; limitform.resetto the closed-to-open transition or a selected status-page ID change.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 16 unresolved issues from previous reviews.
Re-trigger cubic
A single failed CDN fetch set loadError permanently, and loadDatabase threw on it forever after, so one transient blip disabled geo enrichment for the whole process lifetime until a redeploy. It now backs off for ten seconds and retries, which is what links already does by clearing its load promise. The fetch also had no timeout, so a hanging CDN would stall every lookup awaiting it; it now aborts after sixty seconds like links.
There was a problem hiding this comment.
0 issues found across 1 file (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 16 unresolved issues from previous reviews.
Re-trigger cubic
… allowlist closed TopBar.Actions registers into a slot that only the desktop header renders, and that header is hidden below md, so all seventeen pages using it lost their refresh and create controls on mobile. The existing mobile header now renders the same slot, which fixes every page at one point instead of duplicating actions per page. Separately, a self-host deployment can answer requests directly, where a caller sets cf-connecting-ip themselves; the ip allowlist now refuses that header when SELFHOST is set rather than trusting it, while geo enrichment keeps using it.
…ership The no-content-length path compared body.length, which counts UTF-16 code units, against a byte cap, so a payload of multi-byte characters could pass a limit the declared content-length path would have caught; it now uses Buffer.byteLength so both paths agree on units. In the flag manager, lastError was cleared before the promise ownership check, so a stale request that resolved after a newer one had taken the entry still wiped the current error state.
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The ip allowlist is enforced in two places, and only the validateRequest one was hardened, so /track still trusted a spoofable cf-connecting-ip on a self-host deployment and could be walked straight through. Both now use extractAllowlistClientIp. The self-host test also restored SELFHOST by assigning the previous value, which writes the string undefined when the variable was unset, leaving it defined for later tests.
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Confidence score: 3/5
- The integration test mock in
apps/basket/src/routes/track.tsomitsextractAllowlistClientIp, causing Vitest to fail while importing./track; add the missing export to the@utils/ip-geomock to restore test execution.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/basket/src/routes/track.ts">
<violation number="1" location="apps/basket/src/routes/track.ts:29">
P2: The integration test mock for `@utils/ip-geo` does not provide `extractAllowlistClientIp`, so importing `./track` now fails with Vitest’s missing-export mock error. Add this export to the mock before merging.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
cnfast is no longer maintained upstream; its README now points to `cn` as the replacement. All three consumers were a one-line re-export, so the swap is mechanical. Verified byte-identical output across 788 real cn() call sites from this repo: zero mismatches against clsx + tailwind-merge.
The link stats layout was the last direct clsx consumer. cn covers the same joining behaviour, so the dependency comes off the dashboard. Verified identical output on all four class branches these call sites can produce; none of them contain conflicting utilities, so the added merge step is a no-op here.
The helper hand-concatenated its base and state strings, which kept the classes outside the sorted-classes lint rule. Routing it through cn drops the two intermediate bindings and puts the strings back under the linter, which resorted the active branch. Same eleven classes on the active branch and eight on the inactive one; only their order changed, which the class attribute does not care about.
…nfast-with-cn chore(deps): replace deprecated cnfast with cn
enforcePublicFlagRateLimit keyed on `ip || userId`, and userId comes straight off the query string, so a caller with no ip header minted a fresh 600/min bucket per value it invented. Drop it: the limit keys on the client ip, and falls back to one shared bucket when there is none. Callers no longer pass userId, and the scope field collapses to ip or shared now that there is no third case to report.
…p-flag-rate-limit fix(api): stop letting userId partition the public flag rate limit
The no-content-length branch read the whole request into a string before checking its size, so the one path the guard exists to cover was also the one that let a client stream as much as it liked into memory. Read the cloned stream chunk by chunk instead and stop at the first chunk that crosses the cap, so at most one chunk beyond the limit is ever held. The cancel is deliberately not awaited: clone() tees the body, and awaiting the cancel of one branch while the other is never read deadlocks the request. The declared-content-length branch stays as it was. A caller cannot use it to smuggle a larger body, because the server stops reading at the length it was given.
Greptile flagged the floating promise from reader.cancel(). Attach a catch so a failed cancel cannot surface as an unhandled rejection, and say in the code why it is not awaited, since awaiting it deadlocks.
…ors-body-read fix(basket): stop buffering an unbounded /errors body to measure it
Summary by cubic
Promotes staging to main with reliability, performance, privacy, and product fixes across analytics, ingestion, links, uptime, and status pages. Visitor IPs were previously persisted as hashes; they are now discarded entirely, and oversized
/errorsbodies now return 413 before parsing, streaming the body and cutting off at the cap when nocontent-lengthis declared.Reliability and performance
Retry-Aftervalues, including0, prevents stale flag requests from overwriting newer cache entries, keys the public flag rate limit on client IP instead of a caller-supplied userId, and caps BullMQ stack traces.Product and maintenance
next16.3.4, corrects@databuddy/ui’s React peer range to 19, and replaces deprecatedcnfastwithcn.IP_HASH_SALT,REDPANDA_SSL, the production email CLI,husky, andlint-staged;lefthooknow handles repository hooks.Written for commit 9b7da0b. Summary will update on new commits.