Problem
The landing page renders AlertingOverview once per entity type, from inside StatOverview. Concretely, in a tenant with active alerts you get one overflowing alert list under Sources plus two equally-weighted "No Active Alerts" placeholders under Collections and Destinations.
Specific defects, all visible on a real tenant today:
- Alerts are partitioned by entity type.
EntityStatOverview renders three StatOverviews, each of which renders its own AlertingOverview (src/components/home/dashboard/EntityStatOverview/StatOverview.tsx). Urgency has nothing to do with whether a task is a capture or a materialization, so the grouping splits related problems apart and spends equal vertical space on two empty states.
- Nested cards.
AlertingOverview renders a CardWrapper inside the tinted StatOverview container.
- Three concurrent queries for one dataset. Each
AlertingOverview instance runs the same alerts(by: { prefix, active: true }) query and then filters client-side on alertDetails.spec_type. Three round trips, three caches, one dataset.
- Catalog names truncate at the wrong end.
acmeCo/foo/source-post… clips the identifying tail and keeps the redundant prefix.
- No urgency encoding.
shard_failed (task is down) and free_trial_ending render identically as ChipList chips. firedAt exists only as a tooltip, and there is no sort.
- Unbounded growth. The list has no cap, so alert count drives page height.
- Alert type is the whole payload, rendered as a chip — the lowest-information component available for the most important field.
Proposed change
Replace the three per-entity alert lists with one alerts panel below the stat cards.
- Single
alerts(by: { prefix, active: true }) query for the panel.
- Rows read left to right: tier → alert type → task identity → relative time.
- Sorted by tier, then
firedAt.
- Entity type shown as the same iconoir glyph the stat cards use, sourced from
arguments.spec_type (already present in the query response).
- Task identity clips at the head so the identifying tail always survives, with the prefix path as dimmed secondary text.
- 3+ tasks sharing one alert type collapse into a single expandable row.
- Stat cards go back to being only stats. Their existing hooks are untouched.
- Empty state is a single line, not a bordered placeholder card per entity.
Tier derivation
There is no severity field in the API — AlertTypeInfo exposes alertType, description, displayName, isDefault, isSystem, and nothing ranks urgency. The docs assign no severity either.
What is defined is an escalation order, documented for task failures: shard_failed → task_chronically_failing → task_auto_disabled_failing. The default thresholds show the distance: shardFailed fires at 3 failures in 8h, while taskChronicallyFailing.condition.failingFor defaults to 30d.
Proposed client-side tiers, following that documented order:
| Tier |
Rule |
AlertType values, worst first |
| Failing |
Terminal or advanced escalation stage; task stopped or long-erroring |
task_auto_disabled_failing, task_auto_disabled_idle, task_chronically_failing, shard_failed, background_publication_failed |
| Degraded |
First escalation stage, or no-data with the task still alive |
data_movement_stalled, auto_discover_failed, task_idle |
| Billing |
The docs' own Billing Alerts category; tenant-scoped, not a data fault |
missing_payment_method, free_trial_stalled, free_trial_ending, free_trial |
This mapping needs sign-off from someone who triages these daily. It is a UI-side convention, not read from the API. Known soft spots:
data_movement_stalled is only meaningful relative to its configured interval, so a 30-day-interval task firing it is not comparable to a 2-hour one. A flat tier cannot express that.
auto_discover_failed can atomically block unrelated schema fixes, which makes it worse than its tier suggests.
Also in scope
- Source display strings from the API. Replace the hardcoded
humanReadableIntlKey map in src/settings/alerts.ts with displayName / description from the existing alertTypes query. AlertTypeProvider and useGetAlertTypes (src/context/AlertType.tsx) already fetch this. This removes a recurring bug class: background_publication_failed shipped rendering blank because a new alert type has to be registered in two separate places and one was missed. lang/ is en-US only, so there is no translation coverage to lose.
- Rename the page to "Overview." One string:
'routeTitle.home' in src/lang/en-US/RouteTitles.ts, currently Welcome. Everything else already says home (pages/Home.tsx, lang/en-US/HomePage.ts, all home.* keys). "Welcome" greets a first-time visitor, which is not who this page is for. Note details.tabs.overview already exists for entity detail tabs; the two never appear side by side, but don't add a tenant-scoped Overview component next to EntityStatOverview without a clearer name.
- Panel header "Active Alerts." The existing
alerts.overview.title.active string is Active Alerts Summary; drop "Summary" as filler.
- Any "N tasks affected" count must exclude the org-scoped billing types, or it overcounts by one whenever a billing alert is live.
Explicitly out of scope
- No resolve / dismiss / acknowledge affordance. No such mutation exists, and this is by design: alerts are derived state, with
resolvedAt stamped by the system when the condition clears. The four alert mutations (createAlertSubscription, updateAlertSubscription, deleteAlertSubscription, updateAlertConfig) all target subscriptions and configs, never an alert instance. A dismiss button that only hides locally would lie; one wired to updateAlertConfig .enabled = false would silently disable an alert type for the whole prefix.
- Stat card data sources stay on PostgREST.
useActiveEntityCount and useMonthlyUsage are left exactly as they are. No new PostgREST is introduced.
totalCount on ref connections — needs backend, tracked separately (see below).
- A server-side
severity field on AlertTypeInfo — would make the tier mapping authoritative rather than a UI convention, but is not required for this work.
Data and API
Everything the redesign needs already exists in GraphQL. No backend work.
| Need |
Source |
New? |
Alert rows (alertType, firedAt, catalogName, arguments, resolvedAt) |
alerts(by: { prefix, active }) |
Already used |
| Entity type per row |
arguments.spec_type |
Already used for filtering |
| Display names / descriptions |
alertTypes → AlertTypeInfo |
Query + provider already exist |
| Row link targets |
catalogName + useDetailsNavigator (pure URL building, no fetch) |
Existing |
| Tiers, grouping, sort, counts |
derived client-side |
No fetch |
| Optional tenant-level all-alerts page |
same alerts(by: { prefix }) with active omitted; AlertsBy.active is optional and documented to return both active and resolved. Cursor pagination via after/before/first/last on AlertConnection |
No backend |
Where the links go
- Task names → that task's existing Alerts tab (
/captures/details/alerts and the collection/materialization equivalents). The one alert destination that exists today.
- Billing rows →
/admin/billing.
- A tenant-level "all alerts" link has no valid target today. There is no tenant-level alerts route in
app/routes.ts, and components/tables/AlertHistory queries through liveSpecs(by: { names }) so it can only ever show one task. Two lang strings are dead with zero references — alerts.overview.title.active.all ("All Active Alerts") and alerts.overview.listTruncated.cta ("See all active alerts") — and AlertTruncationMessage sets justifyContent: 'space-between' on a Stack with a single child. This CTA was designed and never wired. Either build the route in this work or omit the link. A capped list without a real overflow target just hides alerts.
Accessibility
- Tier glyphs are uniform circles differentiated by colour only. This is acceptable because every glyph has an adjacent text label ("FAILING", "1 failing"), so colour is not the sole carrier and WCAG 1.4.1 holds. Do not use a bare glyph without its label.
- Light mode caveat, measured: the Failing and Degraded dot colours sit at ~1.05:1 against each other (effectively identical luminance) with only a ~57° hue gap, which is the weak axis for red-green CVD. Labels preserve correctness; at-a-glance scanning degrades. Unavoidable within colour-only, since amber cannot be both recognisably amber and 3:1 on white. Filled-vs-hollow circle is the escape hatch if wanted.
- The pulse on Failing rows animates
transform and opacity only, never layout, via a pseudo-element. Must be wrapped in prefers-reduced-motion: reduce.
- The grouped/expandable row needs an accessible name,
aria-expanded, aria-controls, and keyboard activation. Easy to miss: making a <li> role="button" without a label produces an unnamed button.
- Contrast must be re-verified against the real MUI theme values. The numbers from the prototype were measured on prototype-local tokens, not on
context/Theme.tsx exports. Do not assume the shipped values pass or fail; measure with alpha compositing (naive getComputedStyle comparisons ignore rgba compositing and give wrong ratios). If a shared theme token does need changing, note the blast radius across other pages.
Open decisions
- Sort within a tier:
firedAt newest first, or oldest first? Oldest-first argues that a task broken for a month is doing more damage than one broken for two hours.
- Is the tenant-level
/alerts route in scope? Decides whether the overflow link ships or is omitted.
- Row cap: what N, and does the panel need
first: on the query? The current query passes no limit at all, which is a latent problem for tenants with many active alerts independent of this redesign.
- Sign-off on the tier mapping above.
Suggested PR split
- Restructure. Lift
AlertingOverview out of StatOverview into a single panel; collapse three queries into one; stat cards become stats only. No visual tier work yet.
- Tier derivation, grouping, sort, row layout, glyphs + pulse. The presentation layer.
- Wire
alertTypes for display strings, retiring the humanReadableIntlKey map.
- Naming:
routeTitle.home → "Overview", panel header → "Active Alerts".
- (Optional, gated on decision 2) Tenant-level
/alerts route.
Related follow-ups (separate issues, backend)
totalCount on LiveSpecRefConnection. Not a blocker here, but a live correctness bug: RESPONSE_DATA_LIMIT = 1000 and useActiveEntityCount sets indeterminate: data.length >= RESPONSE_DATA_LIMIT, which is what renders the 1351+ on the stat card. The count is capped at the fetch limit and the true value is not shown. A server-side count fixes the wrong number and would also let the stat cards move off PostgREST.
severity on AlertTypeInfo, to make tier ranking authoritative instead of a duplicated UI convention.
Problem
The landing page renders
AlertingOverviewonce per entity type, from insideStatOverview. Concretely, in a tenant with active alerts you get one overflowing alert list under Sources plus two equally-weighted "No Active Alerts" placeholders under Collections and Destinations.Specific defects, all visible on a real tenant today:
EntityStatOverviewrenders threeStatOverviews, each of which renders its ownAlertingOverview(src/components/home/dashboard/EntityStatOverview/StatOverview.tsx). Urgency has nothing to do with whether a task is a capture or a materialization, so the grouping splits related problems apart and spends equal vertical space on two empty states.AlertingOverviewrenders aCardWrapperinside the tintedStatOverviewcontainer.AlertingOverviewinstance runs the samealerts(by: { prefix, active: true })query and then filters client-side onalertDetails.spec_type. Three round trips, three caches, one dataset.acmeCo/foo/source-post…clips the identifying tail and keeps the redundant prefix.shard_failed(task is down) andfree_trial_endingrender identically asChipListchips.firedAtexists only as a tooltip, and there is no sort.Proposed change
Replace the three per-entity alert lists with one alerts panel below the stat cards.
alerts(by: { prefix, active: true })query for the panel.firedAt.arguments.spec_type(already present in the query response).Tier derivation
There is no severity field in the API —
AlertTypeInfoexposesalertType,description,displayName,isDefault,isSystem, and nothing ranks urgency. The docs assign no severity either.What is defined is an escalation order, documented for task failures:
shard_failed→task_chronically_failing→task_auto_disabled_failing. The default thresholds show the distance:shardFailedfires at 3 failures in 8h, whiletaskChronicallyFailing.condition.failingFordefaults to 30d.Proposed client-side tiers, following that documented order:
task_auto_disabled_failing,task_auto_disabled_idle,task_chronically_failing,shard_failed,background_publication_faileddata_movement_stalled,auto_discover_failed,task_idlemissing_payment_method,free_trial_stalled,free_trial_ending,free_trialThis mapping needs sign-off from someone who triages these daily. It is a UI-side convention, not read from the API. Known soft spots:
data_movement_stalledis only meaningful relative to its configured interval, so a 30-day-interval task firing it is not comparable to a 2-hour one. A flat tier cannot express that.auto_discover_failedcan atomically block unrelated schema fixes, which makes it worse than its tier suggests.Also in scope
humanReadableIntlKeymap insrc/settings/alerts.tswithdisplayName/descriptionfrom the existingalertTypesquery.AlertTypeProvideranduseGetAlertTypes(src/context/AlertType.tsx) already fetch this. This removes a recurring bug class:background_publication_failedshipped rendering blank because a new alert type has to be registered in two separate places and one was missed.lang/isen-USonly, so there is no translation coverage to lose.'routeTitle.home'insrc/lang/en-US/RouteTitles.ts, currentlyWelcome. Everything else already sayshome(pages/Home.tsx,lang/en-US/HomePage.ts, allhome.*keys). "Welcome" greets a first-time visitor, which is not who this page is for. Notedetails.tabs.overviewalready exists for entity detail tabs; the two never appear side by side, but don't add a tenant-scopedOverviewcomponent next toEntityStatOverviewwithout a clearer name.alerts.overview.title.activestring isActive Alerts Summary; drop "Summary" as filler.Explicitly out of scope
resolvedAtstamped by the system when the condition clears. The four alert mutations (createAlertSubscription,updateAlertSubscription,deleteAlertSubscription,updateAlertConfig) all target subscriptions and configs, never an alert instance. A dismiss button that only hides locally would lie; one wired toupdateAlertConfig.enabled = falsewould silently disable an alert type for the whole prefix.useActiveEntityCountanduseMonthlyUsageare left exactly as they are. No new PostgREST is introduced.totalCounton ref connections — needs backend, tracked separately (see below).severityfield onAlertTypeInfo— would make the tier mapping authoritative rather than a UI convention, but is not required for this work.Data and API
Everything the redesign needs already exists in GraphQL. No backend work.
alertType,firedAt,catalogName,arguments,resolvedAt)alerts(by: { prefix, active })arguments.spec_typealertTypes→AlertTypeInfocatalogName+useDetailsNavigator(pure URL building, no fetch)alerts(by: { prefix })withactiveomitted;AlertsBy.activeis optional and documented to return both active and resolved. Cursor pagination viaafter/before/first/lastonAlertConnectionWhere the links go
/captures/details/alertsand the collection/materialization equivalents). The one alert destination that exists today./admin/billing.app/routes.ts, andcomponents/tables/AlertHistoryqueries throughliveSpecs(by: { names })so it can only ever show one task. Two lang strings are dead with zero references —alerts.overview.title.active.all("All Active Alerts") andalerts.overview.listTruncated.cta("See all active alerts") — andAlertTruncationMessagesetsjustifyContent: 'space-between'on a Stack with a single child. This CTA was designed and never wired. Either build the route in this work or omit the link. A capped list without a real overflow target just hides alerts.Accessibility
transformandopacityonly, never layout, via a pseudo-element. Must be wrapped inprefers-reduced-motion: reduce.aria-expanded,aria-controls, and keyboard activation. Easy to miss: making a<li>role="button"without a label produces an unnamed button.context/Theme.tsxexports. Do not assume the shipped values pass or fail; measure with alpha compositing (naivegetComputedStylecomparisons ignorergbacompositing and give wrong ratios). If a shared theme token does need changing, note the blast radius across other pages.Open decisions
firedAtnewest first, or oldest first? Oldest-first argues that a task broken for a month is doing more damage than one broken for two hours./alertsroute in scope? Decides whether the overflow link ships or is omitted.first:on the query? The current query passes no limit at all, which is a latent problem for tenants with many active alerts independent of this redesign.Suggested PR split
AlertingOverviewout ofStatOverviewinto a single panel; collapse three queries into one; stat cards become stats only. No visual tier work yet.alertTypesfor display strings, retiring thehumanReadableIntlKeymap.routeTitle.home→ "Overview", panel header → "Active Alerts"./alertsroute.Related follow-ups (separate issues, backend)
totalCountonLiveSpecRefConnection. Not a blocker here, but a live correctness bug:RESPONSE_DATA_LIMIT = 1000anduseActiveEntityCountsetsindeterminate: data.length >= RESPONSE_DATA_LIMIT, which is what renders the1351+on the stat card. The count is capped at the fetch limit and the true value is not shown. A server-side count fixes the wrong number and would also let the stat cards move off PostgREST.severityonAlertTypeInfo, to make tier ranking authoritative instead of a duplicated UI convention.