Skip to content

release: promote staging to main - #714

Open
izadoesdev wants to merge 85 commits into
mainfrom
staging
Open

release: promote staging to main#714
izadoesdev wants to merge 85 commits into
mainfrom
staging

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Sep 2, 2026

Copy link
Copy Markdown
Member
  • fix(links): fail redirects open on analytics and cache mutation failures
  • fix(ui): align uptime and theme-toggle microinteractions to motion tokens
  • fix(status): adopt motion tokens and stop collapse and loading snaps
  • fix(dashboard): make the reduced-motion guard actually apply
  • perf(db): repartition identity pair maps for prunable window reads
  • perf(rpc): resolve session identity in one pass over the event union
  • perf(ingest): bound async insert waits and cap user agent length
  • refactor(links): deliver click visits straight to redpanda
  • refactor(links): type the lookup source and clarify redirect helper names
  • chore(email): use granular react-email packages and drop the cli from production deps
  • perf(dashboard): dedupe qr libraries, scope shiki, and lazy load the auth background
  • perf(basket): cache user agent parsing and cap oversized error payloads
  • fix(dashboard): read stripe snake_case card expiry in billing
  • fix(uptime): back monitor state with Redis and keep failure streaks across read failures
  • fix(rpc): stop the status page reporting healthy during real incidents
  • fix(ui): bucket uptime heatmap days in UTC to match the stored data
  • perf(status): cut first-load weight with woff2 fonts and narrower imports
  • refactor(ui): split nucleo icons into one file per icon
  • refactor(ui): split the icon barrel into per-icon modules
  • chore(ui): bring the design system back under lint and format
  • refactor(status): drop the local error icon module
  • ci(docker): share the health smoke script and pin installs to the lockfile
  • perf(ui): load recharts only when a latency chart is expanded
  • ci(docker): restore the per-app smoke script and unpin lockfile installs
  • ci(docker): drop the uptime image smoke stage
  • refactor(dashboard): drop the dead weight prop from icon call sites
  • refactor(docs): drop the dead weight prop from icon call sites
  • refactor(ui): remove the weight prop and displayNames from icons
  • refactor(ui): drop the weight prop from EmptyState
  • fix(ui): give SettingCard a real toggle and modernise Input's ref
  • fix(devtools): associate flag panel labels and drop invalid resize ARIA
  • refactor(ui): delete the unused cells heatmap variant
  • refactor(ui): move latency chart helpers next to their only consumer
  • perf(ui): stop the uptime strip re-rendering on every pointer move
  • fix(ui): make the uptime strip navigable instead of 90 tab stops
  • fix(ui): encode uptime severity in bar height, not colour alone
  • chore(deps): upgrade bun to 1.4.0
  • feat(status): add a Get your own CTA and enlarge the footer
  • refactor(uptime): inline monitor state helpers into transition alerts
  • chore(deps): upgrade next to 16.3.4 and route every app through the catalog
  • refactor(privacy): stop persisting hashed visitor ips
  • chore(deps): pin every next copy to 16.3.4
  • feat(status-pages): upload logos and favicons to object storage
  • fix(deps): stop emitting standalone output on vercel
  • fix(dashboard): make monitors and status pages match the rest of the app

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 /errors bodies now return 413 before parsing, streaming the body and cutting off at the cap when no content-length is declared.

Reliability and performance

  • Persists monitor state and failure streaks in Redis and combines unresolved incidents with monitor health on status pages.
  • Honors SDK Retry-After values, including 0, 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.
  • Keeps link redirects working when analytics or cache writes fail, sends visits directly to Redpanda over mandatory TLS, and centralizes client IP handling.
  • Rejects spoofable IP allowlist headers in self-hosted deployments across both enforcement paths while preserving geo enrichment.
  • Lets geo enrichment recover from failed database loads instead of staying disabled until the next deploy.
  • Bounds ingestion waits and payloads, caches user-agent parsing, reduces database and ClickHouse work, and adds query-budget coverage.
  • Reduces dashboard and status-page load costs with lazy-loaded charts and auth visuals, tree-shaken icons, narrower imports, and smaller fonts.

Product and maintenance

  • Adds status-page logo and favicon uploads, detail-page editing with restored titles and accessible labels, a reset edit sheet, a “Get your own” CTA, and improved indexing behavior.
  • Restores monitor and status-page actions on mobile and fixes Stripe card expiry rendering, UTC uptime labels, reduced-motion handling, loading transitions, and uptime keyboard navigation.
  • Aligns CI and Docker builds on Bun 1.4.0 and next 16.3.4, corrects @databuddy/ui’s React peer range to 19, and replaces deprecated cnfast with cn.
  • Removes IP_HASH_SALT, REDPANDA_SSL, the production email CLI, husky, and lint-staged; lefthook now handles repository hooks.

Written for commit 9b7da0b. Summary will update on new commits.

Review in cubic

…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/links/src/lib/producer.ts
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/sdk/src/core/flags/flags-manager.ts
Comment thread apps/basket/src/routes/basket.ts Outdated
Comment thread apps/basket/src/routes/basket.ts Outdated
…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; limit form.reset to 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/basket/src/lib/request-validation.ts
Comment thread apps/basket/src/lib/request-validation-logic.test.ts Outdated
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Confidence score: 3/5

  • The integration test mock in apps/basket/src/routes/track.ts omits extractAllowlistClientIp, causing Vitest to fail while importing ./track; add the missing export to the @utils/ip-geo mock 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

Comment thread apps/basket/src/routes/track.ts
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant