@checkstack/notification-frontend 0.4.4 → 0.4.5
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.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,155 @@
|
|
|
1
1
|
# @checkstack/notification-frontend
|
|
2
2
|
|
|
3
|
+
## 0.4.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f23f3c9: Add per-channel notification delivery-attempt tracking
|
|
8
|
+
(Phase 8 of the v1 polishing plan). The external dispatch loop now
|
|
9
|
+
persists one row per `strategy.send(...)` call into a new
|
|
10
|
+
`notification_delivery_attempts` table - both successes and
|
|
11
|
+
failures - so silent delivery breakage (misconfigured webhooks, dead
|
|
12
|
+
channels) becomes queryable instead of buried in logs.
|
|
13
|
+
|
|
14
|
+
- `@checkstack/notification-backend` adds the
|
|
15
|
+
`notification_delivery_attempts` table, the matching Drizzle
|
|
16
|
+
migration, and a new `dispatchWithAttempt` helper that wraps every
|
|
17
|
+
external `strategy.send(...)` with duration measurement and
|
|
18
|
+
best-effort row persistence. The insert is intentionally
|
|
19
|
+
fire-and-forget: if writing the attempt row itself errors, the
|
|
20
|
+
dispatch loop logs and continues so visibility tracking can never
|
|
21
|
+
introduce a _new_ silent failure.
|
|
22
|
+
- `@checkstack/notification-common` exports a new
|
|
23
|
+
`DeliveryAttemptSchema` zod schema, the
|
|
24
|
+
`ListDeliveryAttemptsInputSchema =
|
|
25
|
+
PaginationInput.extend({ notificationId })` input, and a new
|
|
26
|
+
`getDeliveryAttempts` procedure on the contract. The procedure is
|
|
27
|
+
gated by the existing `notificationAccess.admin`
|
|
28
|
+
(`notification:manage`) access rule - no new permission was
|
|
29
|
+
introduced.
|
|
30
|
+
- `@checkstack/notification-frontend` adds a minimal admin-only
|
|
31
|
+
`DeliveryAttemptsPage` (route id `notification.deliveryAttempts`,
|
|
32
|
+
path `/notifications/delivery-attempts`) and an "Open inspector"
|
|
33
|
+
link from the Notification Settings page for users with
|
|
34
|
+
`notification:manage`. No client-side `isAdmin` gate - the FORBIDDEN
|
|
35
|
+
case is rendered via the standard error-state branch on the page,
|
|
36
|
+
enforced by the contract.
|
|
37
|
+
|
|
38
|
+
Visibility only: there is no retry mechanism in this phase. A
|
|
39
|
+
`failure` row is a final outcome an admin actions manually
|
|
40
|
+
(re-trigger the source event, fix the misconfigured channel).
|
|
41
|
+
Automated retries are deferred to v1.1.
|
|
42
|
+
|
|
43
|
+
Strategy errors thrown during `send(...)` are persisted via
|
|
44
|
+
`extractErrorMessage(error)` so secrets potentially embedded in raw
|
|
45
|
+
error objects (webhook URLs, OAuth tokens reachable from the strategy
|
|
46
|
+
send context) are not stored verbatim.
|
|
47
|
+
|
|
48
|
+
See the new
|
|
49
|
+
`docs/src/content/docs/backend/notification-delivery.md` page for the
|
|
50
|
+
full surface description.
|
|
51
|
+
|
|
52
|
+
- f23f3c9: Establish the canonical optimistic-UI pattern for oRPC mutations
|
|
53
|
+
(`onMutate` snapshot / patch, `onError` rollback, `onSettled`
|
|
54
|
+
invalidate) and apply it to the two highest-frequency toggles where
|
|
55
|
+
perceived latency was most visible:
|
|
56
|
+
|
|
57
|
+
- `markAsRead` on the Notifications page — clicking the check on a
|
|
58
|
+
notification card now flips the read state immediately instead of
|
|
59
|
+
waiting for the round-trip.
|
|
60
|
+
- `pauseConfiguration` / `resumeConfiguration` on the Health Check
|
|
61
|
+
Config page — pause/resume now flip the row's badge instantly,
|
|
62
|
+
rolling back on server error.
|
|
63
|
+
|
|
64
|
+
The wrapper type for `useMutation` on each plugin client gained an
|
|
65
|
+
optional `TContext` generic so optimistic sites can return a snapshot
|
|
66
|
+
from `onMutate` and consume it in `onError` without `unknown` casts.
|
|
67
|
+
The runtime behaviour and the auto-invalidation on success are
|
|
68
|
+
unchanged; the change is additive on the type surface only.
|
|
69
|
+
|
|
70
|
+
Full pattern and "when NOT to use it" guidance live in
|
|
71
|
+
`docs/frontend/optimistic-updates.md`.
|
|
72
|
+
|
|
73
|
+
- f23f3c9: Sweep every paginated `*-common` contract onto the canonical
|
|
74
|
+
`PaginationInput` / `PaginatedResult` from `@checkstack/common` and
|
|
75
|
+
remove the now-unused legacy exports.
|
|
76
|
+
|
|
77
|
+
**BREAKING CHANGE** - `@checkstack/common` drops the deprecated
|
|
78
|
+
`PaginationInputSchema`, `paginatedOutput`, and `PaginatedResponse`
|
|
79
|
+
symbols. Callers must consume `PaginationInput` (input) and
|
|
80
|
+
`PaginatedResult(itemSchema)` (output) instead. The canonical input is
|
|
81
|
+
`{ limit (1-100, default 20), offset (>= 0, default 0) }`; the
|
|
82
|
+
canonical output envelope is
|
|
83
|
+
`{ items, total, limit, offset }`.
|
|
84
|
+
|
|
85
|
+
**BREAKING CHANGE** - `@checkstack/notification-common` migrates
|
|
86
|
+
`getNotifications` off the legacy `PaginationInputSchema`
|
|
87
|
+
(`{ limit, offset, unreadOnly }` with output `{ notifications, total }`)
|
|
88
|
+
onto `ListNotificationsInputSchema =
|
|
89
|
+
PaginationInput.extend({ unreadOnly })` and
|
|
90
|
+
`PaginatedResult(NotificationSchema)`. The output key changes from
|
|
91
|
+
`notifications` to `items`, and `limit` / `offset` are now echoed on
|
|
92
|
+
the response. The `PaginationInput` type alias previously exported
|
|
93
|
+
from `notification-common` is removed - use `ListNotificationsInput`
|
|
94
|
+
or the canonical `PaginationInput` from `@checkstack/common`.
|
|
95
|
+
|
|
96
|
+
**BREAKING CHANGE** - `@checkstack/integration-common` migrates
|
|
97
|
+
`listSubscriptions` (inline `{ page, pageSize, ... }` -> output
|
|
98
|
+
`{ subscriptions, total }`) and `getDeliveryLogs` (via
|
|
99
|
+
`DeliveryLogQueryInputSchema` `{ subscriptionId?, eventType?, status?,
|
|
100
|
+
page, pageSize }` -> output `{ logs, total }`) onto the canonical
|
|
101
|
+
`PaginationInput.extend({...})` input and
|
|
102
|
+
`PaginatedResult(itemSchema)` output. External callers must switch
|
|
103
|
+
from `{ page, pageSize }` to `{ limit, offset }` and read response
|
|
104
|
+
items from `data.items` (no more `data.subscriptions` / `data.logs`).
|
|
105
|
+
|
|
106
|
+
The matching `*-backend` handlers were updated to consume the new
|
|
107
|
+
input shape (`offset` arithmetic in lieu of `(page - 1) * pageSize`)
|
|
108
|
+
and to echo `limit` / `offset` on the response. The `*-frontend` call
|
|
109
|
+
sites in `NotificationsPage`, `NotificationBell`, `IntegrationsPage`,
|
|
110
|
+
and `DeliveryLogsPage` were updated to send the new input shape and
|
|
111
|
+
read `data.items`.
|
|
112
|
+
|
|
113
|
+
- f23f3c9: Gate decorative motion and blur effects behind
|
|
114
|
+
`usePerformance().isLowPower` on a focused set of high-traffic plugin
|
|
115
|
+
pages (Dashboard, Dependency map, System node, Notification bell,
|
|
116
|
+
Announcement banner / cards, Anomaly field overrides editor, SLO
|
|
117
|
+
attribution chart, Catalog droppable group). Hover scales, backdrop
|
|
118
|
+
blurs, `animate-pulse`/`animate-ping` accents, and entry transitions
|
|
119
|
+
now drop to static states on low-power devices; functional UX
|
|
120
|
+
transitions (Drawer/Dialog open-close, colour transitions) are left
|
|
121
|
+
alone.
|
|
122
|
+
|
|
123
|
+
Standardise the post-mutation error-toast voice on plugin pages by
|
|
124
|
+
migrating multi-clause `toast.error(extractErrorMessage(error, "Failed
|
|
125
|
+
to X"))` call sites onto the `toastError(toast, "Failed to X", error)`
|
|
126
|
+
helper from `@checkstack/ui`. The helper applies the canonical
|
|
127
|
+
`"action: message"` prefix and 100-character truncation in one place,
|
|
128
|
+
and the now-orphaned `extractErrorMessage` imports are dropped from
|
|
129
|
+
the affected files. No business logic or component APIs changed.
|
|
130
|
+
|
|
131
|
+
- f23f3c9: Standardise the empty / loading / error story on key list pages using
|
|
132
|
+
the shared `ListEmptyState`, `QueryErrorState`, and `Skeleton`
|
|
133
|
+
primitives from `@checkstack/ui`. Each affected page now branches
|
|
134
|
+
through the same `isLoading -> isError -> empty -> data` ladder, so
|
|
135
|
+
failed queries surface a retry-able inline error instead of silently
|
|
136
|
+
rendering an empty table, and loading states match the final layout
|
|
137
|
+
rather than flashing a generic spinner. No layout, business logic, or
|
|
138
|
+
query input shapes changed.
|
|
139
|
+
- Updated dependencies [f23f3c9]
|
|
140
|
+
- Updated dependencies [f23f3c9]
|
|
141
|
+
- Updated dependencies [f23f3c9]
|
|
142
|
+
- Updated dependencies [f23f3c9]
|
|
143
|
+
- Updated dependencies [f23f3c9]
|
|
144
|
+
- Updated dependencies [f23f3c9]
|
|
145
|
+
- @checkstack/common@0.11.0
|
|
146
|
+
- @checkstack/auth-frontend@0.6.5
|
|
147
|
+
- @checkstack/notification-common@1.2.0
|
|
148
|
+
- @checkstack/frontend-api@0.5.2
|
|
149
|
+
- @checkstack/ui@1.10.0
|
|
150
|
+
- @checkstack/tips-frontend@0.2.5
|
|
151
|
+
- @checkstack/signal-frontend@0.1.4
|
|
152
|
+
|
|
3
153
|
## 0.4.4
|
|
4
154
|
|
|
5
155
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-frontend",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.tsx",
|
|
@@ -13,13 +13,13 @@
|
|
|
13
13
|
"lint:code": "eslint . --max-warnings 0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@checkstack/auth-frontend": "0.6.
|
|
16
|
+
"@checkstack/auth-frontend": "0.6.4",
|
|
17
17
|
"@checkstack/common": "0.10.0",
|
|
18
18
|
"@checkstack/frontend-api": "0.5.1",
|
|
19
|
-
"@checkstack/notification-common": "1.1.
|
|
19
|
+
"@checkstack/notification-common": "1.1.1",
|
|
20
20
|
"@checkstack/signal-frontend": "0.1.3",
|
|
21
|
-
"@checkstack/tips-frontend": "0.2.
|
|
22
|
-
"@checkstack/ui": "1.
|
|
21
|
+
"@checkstack/tips-frontend": "0.2.4",
|
|
22
|
+
"@checkstack/ui": "1.9.0",
|
|
23
23
|
"lucide-react": "^0.344.0",
|
|
24
24
|
"react": "^18.2.0",
|
|
25
25
|
"react-router-dom": "^6.22.0"
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
stripMarkdown,
|
|
16
16
|
useToast,
|
|
17
17
|
useIsMobile,
|
|
18
|
+
usePerformance,
|
|
19
|
+
cn,
|
|
18
20
|
} from "@checkstack/ui";
|
|
19
21
|
import { useApi, usePluginClient } from "@checkstack/frontend-api";
|
|
20
22
|
import { resolveRoute } from "@checkstack/common";
|
|
@@ -29,6 +31,7 @@ import { ChevronDown, ChevronUp } from "lucide-react";
|
|
|
29
31
|
import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
30
32
|
|
|
31
33
|
export const NotificationBell = () => {
|
|
34
|
+
const { isLowPower } = usePerformance();
|
|
32
35
|
const authApi = useApi(authApiRef);
|
|
33
36
|
const { data: session, isPending: isAuthLoading } = authApi.useSession();
|
|
34
37
|
const notificationClient = usePluginClient(NotificationApi);
|
|
@@ -72,7 +75,7 @@ export const NotificationBell = () => {
|
|
|
72
75
|
const markAsReadMutation = notificationClient.markAsRead.useMutation();
|
|
73
76
|
|
|
74
77
|
const unreadCount = unreadData?.count ?? 0;
|
|
75
|
-
const recentNotifications = notificationsData?.
|
|
78
|
+
const recentNotifications = notificationsData?.items ?? [];
|
|
76
79
|
|
|
77
80
|
const handleMarkAllAsRead = async () => {
|
|
78
81
|
try {
|
|
@@ -102,10 +105,17 @@ export const NotificationBell = () => {
|
|
|
102
105
|
|
|
103
106
|
const trigger = (
|
|
104
107
|
<Button variant="ghost" size="icon" className="relative group">
|
|
105
|
-
<Bell
|
|
108
|
+
<Bell
|
|
109
|
+
className={cn(
|
|
110
|
+
"h-5 w-5",
|
|
111
|
+
!isLowPower && "transition-transform group-hover:scale-110",
|
|
112
|
+
)}
|
|
113
|
+
/>
|
|
106
114
|
{unreadCount > 0 && (
|
|
107
115
|
<span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
|
|
108
|
-
|
|
116
|
+
{!isLowPower && (
|
|
117
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
|
|
118
|
+
)}
|
|
109
119
|
<Badge
|
|
110
120
|
variant="destructive"
|
|
111
121
|
className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
|
package/src/index.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { NotificationBell } from "./components/NotificationBell";
|
|
12
12
|
import { NotificationsPage } from "./pages/NotificationsPage";
|
|
13
13
|
import { NotificationSettingsPage } from "./pages/NotificationSettingsPage";
|
|
14
|
+
import { DeliveryAttemptsPage } from "./pages/DeliveryAttemptsPage";
|
|
14
15
|
import { NotificationUserMenuItems } from "./components/UserMenuItems";
|
|
15
16
|
|
|
16
17
|
// Plugin-extensible kind registry — domain frontends call `registerSubjectKind`
|
|
@@ -47,6 +48,10 @@ export const notificationPlugin = createFrontendPlugin({
|
|
|
47
48
|
route: notificationRoutes.routes.settings,
|
|
48
49
|
element: <NotificationSettingsPage />,
|
|
49
50
|
},
|
|
51
|
+
{
|
|
52
|
+
route: notificationRoutes.routes.deliveryAttempts,
|
|
53
|
+
element: <DeliveryAttemptsPage />,
|
|
54
|
+
},
|
|
50
55
|
],
|
|
51
56
|
extensions: [
|
|
52
57
|
{
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Send } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
PageLayout,
|
|
5
|
+
Badge,
|
|
6
|
+
Button,
|
|
7
|
+
Card,
|
|
8
|
+
Table,
|
|
9
|
+
TableHeader,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableRow,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableCell,
|
|
14
|
+
Alert,
|
|
15
|
+
LoadingSpinner,
|
|
16
|
+
EmptyState,
|
|
17
|
+
} from "@checkstack/ui";
|
|
18
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
19
|
+
import { NotificationApi } from "@checkstack/notification-common";
|
|
20
|
+
import type { DeliveryAttempt } from "@checkstack/notification-common";
|
|
21
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
22
|
+
|
|
23
|
+
const PAGE_SIZE = 25;
|
|
24
|
+
const ERROR_MESSAGE_MAX = 120;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Truncate a sanitised error message to keep the table column tidy.
|
|
28
|
+
* The full message is still available on hover via `title`.
|
|
29
|
+
*/
|
|
30
|
+
const truncate = (value: string, max: number): string =>
|
|
31
|
+
value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format an attempt timestamp as local-time with a relative hint.
|
|
35
|
+
* Mirrors `NotificationsPage`'s `formatDate` voice without depending
|
|
36
|
+
* on it (a tiny helper, not worth a shared util yet).
|
|
37
|
+
*/
|
|
38
|
+
const formatAttemptedAt = (raw: Date | string): string => {
|
|
39
|
+
const d = new Date(raw);
|
|
40
|
+
return d.toLocaleString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const StatusBadge = ({ status }: { status: DeliveryAttempt["status"] }) => {
|
|
44
|
+
if (status === "success") {
|
|
45
|
+
return <Badge variant="success">Success</Badge>;
|
|
46
|
+
}
|
|
47
|
+
return <Badge variant="destructive">Failure</Badge>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Admin-only visibility surface for per-channel notification delivery
|
|
52
|
+
* attempts. Shows the most recent attempts (newest first); failures
|
|
53
|
+
* expose the silent-failure mode the dispatch loop swallowed pre-v1.
|
|
54
|
+
*
|
|
55
|
+
* Scope is intentionally minimal — no filter chips, charts, or
|
|
56
|
+
* exports. Retries are deferred to v1.1; this is visibility only.
|
|
57
|
+
*/
|
|
58
|
+
export const DeliveryAttemptsPage = () => {
|
|
59
|
+
const notificationClient = usePluginClient(NotificationApi);
|
|
60
|
+
const [page, setPage] = useState(0);
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
data,
|
|
64
|
+
isLoading,
|
|
65
|
+
isError,
|
|
66
|
+
error,
|
|
67
|
+
refetch,
|
|
68
|
+
} = notificationClient.getDeliveryAttempts.useQuery({
|
|
69
|
+
limit: PAGE_SIZE,
|
|
70
|
+
offset: page * PAGE_SIZE,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const attempts = data?.items ?? [];
|
|
74
|
+
const total = data?.total ?? 0;
|
|
75
|
+
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
76
|
+
|
|
77
|
+
// No client-side `isAdmin` gate: the `getDeliveryAttempts` procedure
|
|
78
|
+
// is locked behind `notificationAccess.admin` at the contract layer
|
|
79
|
+
// (see `core/notification-common/src/rpc-contract.ts`). A caller
|
|
80
|
+
// without the `notification:manage` access rule receives FORBIDDEN
|
|
81
|
+
// from the server, which we render below via the standard
|
|
82
|
+
// error-state branch. The nav entry is hidden cosmetically by the
|
|
83
|
+
// existing access check on `NotificationSettingsPage` — security is
|
|
84
|
+
// enforced by the contract, not the UI.
|
|
85
|
+
return (
|
|
86
|
+
<PageLayout title="Delivery Attempts" icon={Send}>
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
<p className="text-sm text-muted-foreground">
|
|
89
|
+
Per-channel outcomes from the external notification dispatch
|
|
90
|
+
loop. Failures are surfaced here so silent delivery breakage
|
|
91
|
+
(misconfigured webhooks, dead channels) becomes actionable.
|
|
92
|
+
Retries are not implemented yet - this is visibility only.
|
|
93
|
+
</p>
|
|
94
|
+
|
|
95
|
+
{isLoading ? (
|
|
96
|
+
<Card className="p-8">
|
|
97
|
+
<div className="flex justify-center">
|
|
98
|
+
<LoadingSpinner size="md" />
|
|
99
|
+
</div>
|
|
100
|
+
</Card>
|
|
101
|
+
) : isError ? (
|
|
102
|
+
<Alert variant="error">
|
|
103
|
+
<div className="flex items-center justify-between gap-4">
|
|
104
|
+
<span>
|
|
105
|
+
Failed to load delivery attempts:{" "}
|
|
106
|
+
{extractErrorMessage(error, "Unknown error")}
|
|
107
|
+
</span>
|
|
108
|
+
<Button
|
|
109
|
+
variant="outline"
|
|
110
|
+
size="sm"
|
|
111
|
+
onClick={() => {
|
|
112
|
+
void refetch();
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
Retry
|
|
116
|
+
</Button>
|
|
117
|
+
</div>
|
|
118
|
+
</Alert>
|
|
119
|
+
) : attempts.length === 0 ? (
|
|
120
|
+
<EmptyState
|
|
121
|
+
icon={<Send className="h-12 w-12" />}
|
|
122
|
+
title="No delivery attempts yet"
|
|
123
|
+
description="Once notifications are dispatched via an external channel, each attempt will appear here."
|
|
124
|
+
/>
|
|
125
|
+
) : (
|
|
126
|
+
<Card className="p-0 overflow-hidden">
|
|
127
|
+
<Table>
|
|
128
|
+
<TableHeader>
|
|
129
|
+
<TableRow>
|
|
130
|
+
<TableHead>Attempted at</TableHead>
|
|
131
|
+
<TableHead>Strategy</TableHead>
|
|
132
|
+
<TableHead>Status</TableHead>
|
|
133
|
+
<TableHead>Duration</TableHead>
|
|
134
|
+
<TableHead>Error</TableHead>
|
|
135
|
+
</TableRow>
|
|
136
|
+
</TableHeader>
|
|
137
|
+
<TableBody>
|
|
138
|
+
{attempts.map((attempt) => (
|
|
139
|
+
<TableRow key={attempt.id}>
|
|
140
|
+
<TableCell className="whitespace-nowrap">
|
|
141
|
+
{formatAttemptedAt(attempt.attemptedAt)}
|
|
142
|
+
</TableCell>
|
|
143
|
+
<TableCell className="font-mono text-xs">
|
|
144
|
+
{attempt.strategyQualifiedId}
|
|
145
|
+
</TableCell>
|
|
146
|
+
<TableCell>
|
|
147
|
+
<StatusBadge status={attempt.status} />
|
|
148
|
+
</TableCell>
|
|
149
|
+
<TableCell className="whitespace-nowrap text-muted-foreground">
|
|
150
|
+
{attempt.durationMs}ms
|
|
151
|
+
</TableCell>
|
|
152
|
+
<TableCell
|
|
153
|
+
className="max-w-md text-xs text-muted-foreground"
|
|
154
|
+
title={attempt.errorMessage ?? undefined}
|
|
155
|
+
>
|
|
156
|
+
{attempt.errorMessage
|
|
157
|
+
? truncate(attempt.errorMessage, ERROR_MESSAGE_MAX)
|
|
158
|
+
: "-"}
|
|
159
|
+
</TableCell>
|
|
160
|
+
</TableRow>
|
|
161
|
+
))}
|
|
162
|
+
</TableBody>
|
|
163
|
+
</Table>
|
|
164
|
+
</Card>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{total > PAGE_SIZE && (
|
|
168
|
+
<div className="flex items-center justify-center gap-2">
|
|
169
|
+
<Button
|
|
170
|
+
variant="outline"
|
|
171
|
+
size="sm"
|
|
172
|
+
disabled={page === 0}
|
|
173
|
+
onClick={() => {
|
|
174
|
+
setPage((p) => Math.max(0, p - 1));
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
Previous
|
|
178
|
+
</Button>
|
|
179
|
+
<span className="text-sm text-muted-foreground">
|
|
180
|
+
Page {page + 1} of {pageCount}
|
|
181
|
+
</span>
|
|
182
|
+
<Button
|
|
183
|
+
variant="outline"
|
|
184
|
+
size="sm"
|
|
185
|
+
disabled={(page + 1) * PAGE_SIZE >= total}
|
|
186
|
+
onClick={() => {
|
|
187
|
+
setPage((p) => p + 1);
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
Next
|
|
191
|
+
</Button>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</PageLayout>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Bell, Clock, Zap, Send, Activity } from "lucide-react";
|
|
3
4
|
import {
|
|
4
5
|
PageLayout,
|
|
5
6
|
Card,
|
|
@@ -17,8 +18,10 @@ import type { EnrichedSubscription } from "@checkstack/notification-common";
|
|
|
17
18
|
import {
|
|
18
19
|
NotificationApi,
|
|
19
20
|
notificationAccess,
|
|
21
|
+
notificationRoutes,
|
|
20
22
|
pluginMetadata as notificationPluginMetadata,
|
|
21
23
|
} from "@checkstack/notification-common";
|
|
24
|
+
import { resolveRoute } from "@checkstack/common";
|
|
22
25
|
import { TipBanner } from "@checkstack/tips-frontend";
|
|
23
26
|
import {
|
|
24
27
|
StrategyCard,
|
|
@@ -389,6 +392,34 @@ export const NotificationSettingsPage = () => {
|
|
|
389
392
|
</section>
|
|
390
393
|
)}
|
|
391
394
|
|
|
395
|
+
{/* Delivery Attempts inspector link - Admin only */}
|
|
396
|
+
{isAdmin && (
|
|
397
|
+
<section>
|
|
398
|
+
<SectionHeader
|
|
399
|
+
title="Delivery Attempts"
|
|
400
|
+
description="Inspect per-channel delivery outcomes for recent notifications (admin only)."
|
|
401
|
+
icon={<Activity className="h-5 w-5" />}
|
|
402
|
+
/>
|
|
403
|
+
<Card className="p-4">
|
|
404
|
+
<div className="flex items-center justify-between gap-4">
|
|
405
|
+
<p className="text-sm text-muted-foreground">
|
|
406
|
+
See every external `strategy.send(...)` outcome - useful
|
|
407
|
+
for debugging silent failures on misconfigured channels.
|
|
408
|
+
</p>
|
|
409
|
+
<Button asChild variant="outline" size="sm">
|
|
410
|
+
<Link
|
|
411
|
+
to={resolveRoute(
|
|
412
|
+
notificationRoutes.routes.deliveryAttempts,
|
|
413
|
+
)}
|
|
414
|
+
>
|
|
415
|
+
Open inspector
|
|
416
|
+
</Link>
|
|
417
|
+
</Button>
|
|
418
|
+
</div>
|
|
419
|
+
</Card>
|
|
420
|
+
</section>
|
|
421
|
+
)}
|
|
422
|
+
|
|
392
423
|
{/* Retention Policy - Admin only */}
|
|
393
424
|
{isAdmin && retentionSchema && (
|
|
394
425
|
<section>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo } from "react";
|
|
2
2
|
import { Link } from "react-router-dom";
|
|
3
3
|
import { Bell, Check, Trash2, ChevronDown, ChevronUp } from "lucide-react";
|
|
4
4
|
import {
|
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
Badge,
|
|
7
7
|
Button,
|
|
8
8
|
Card,
|
|
9
|
+
ListEmptyState,
|
|
10
|
+
QueryErrorState,
|
|
11
|
+
Skeleton,
|
|
9
12
|
useToast,
|
|
13
|
+
toastError,
|
|
10
14
|
Popover,
|
|
11
15
|
PopoverContent,
|
|
12
16
|
PopoverTrigger,
|
|
@@ -14,16 +18,27 @@ import {
|
|
|
14
18
|
MenuCloseContext,
|
|
15
19
|
Markdown,
|
|
16
20
|
} from "@checkstack/ui";
|
|
17
|
-
import { usePluginClient } from "@checkstack/frontend-api";
|
|
21
|
+
import { usePluginClient, useQueryClient } from "@checkstack/frontend-api";
|
|
18
22
|
import type { Notification } from "@checkstack/notification-common";
|
|
19
23
|
import { NotificationApi } from "@checkstack/notification-common";
|
|
20
|
-
import { extractErrorMessage } from "@checkstack/common";
|
|
24
|
+
import { extractErrorMessage, type InferClient } from "@checkstack/common";
|
|
21
25
|
import { NotificationSubjects } from "../components/NotificationSubjects";
|
|
22
26
|
import { groupByCollapseKey } from "../components/collapse";
|
|
23
27
|
import { CollapsedGroupTimeline } from "../components/CollapsedGroupTimeline";
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Cached output of the `notification.getNotifications` query, derived
|
|
31
|
+
* directly from the contract so a future change to the procedure's
|
|
32
|
+
* output shape surfaces as a typecheck error in this file rather than
|
|
33
|
+
* a runtime mismatch between the cache and the optimistic patch.
|
|
34
|
+
*/
|
|
35
|
+
type NotificationsQueryData = Awaited<
|
|
36
|
+
ReturnType<InferClient<typeof NotificationApi>["getNotifications"]>
|
|
37
|
+
>;
|
|
38
|
+
|
|
25
39
|
export const NotificationsPage = () => {
|
|
26
40
|
const notificationClient = usePluginClient(NotificationApi);
|
|
41
|
+
const queryClient = useQueryClient();
|
|
27
42
|
const toast = useToast();
|
|
28
43
|
|
|
29
44
|
const [filter, setFilter] = useState<"all" | "unread">("all");
|
|
@@ -46,30 +61,79 @@ export const NotificationsPage = () => {
|
|
|
46
61
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
|
47
62
|
const pageSize = 20;
|
|
48
63
|
|
|
64
|
+
// Query input — captured once so the loader and the optimistic
|
|
65
|
+
// `markAsRead` patch agree on the exact query-key oRPC builds. Changes
|
|
66
|
+
// to filter/page rebuild the memo, which rebuilds the key, which keeps
|
|
67
|
+
// the optimistic write aimed at the cache entry the user is looking at.
|
|
68
|
+
const notificationsQueryInput = useMemo(
|
|
69
|
+
() => ({
|
|
70
|
+
limit: pageSize,
|
|
71
|
+
offset: page * pageSize,
|
|
72
|
+
unreadOnly: filter === "unread",
|
|
73
|
+
}),
|
|
74
|
+
[page, pageSize, filter],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Mirrors oRPC's `generateOperationKey([path], { type, input })` shape;
|
|
78
|
+
// see `docs/frontend/optimistic-updates.md` for the contract.
|
|
79
|
+
const notificationsQueryKey = useMemo(
|
|
80
|
+
() =>
|
|
81
|
+
[
|
|
82
|
+
["notification", "getNotifications"],
|
|
83
|
+
{ input: notificationsQueryInput, type: "query" },
|
|
84
|
+
] as const,
|
|
85
|
+
[notificationsQueryInput],
|
|
86
|
+
);
|
|
87
|
+
|
|
49
88
|
// Query: Fetch notifications
|
|
89
|
+
const notificationsQuery =
|
|
90
|
+
notificationClient.getNotifications.useQuery(notificationsQueryInput);
|
|
50
91
|
const {
|
|
51
92
|
data: notificationsData,
|
|
52
93
|
isLoading: loading,
|
|
53
94
|
refetch,
|
|
54
|
-
} =
|
|
55
|
-
limit: pageSize,
|
|
56
|
-
offset: page * pageSize,
|
|
57
|
-
unreadOnly: filter === "unread",
|
|
58
|
-
});
|
|
95
|
+
} = notificationsQuery;
|
|
59
96
|
|
|
60
|
-
const notifications = notificationsData?.
|
|
97
|
+
const notifications = notificationsData?.items ?? [];
|
|
61
98
|
const total = notificationsData?.total ?? 0;
|
|
62
99
|
|
|
63
|
-
// Mutation: Mark as read
|
|
100
|
+
// Mutation: Mark as read — optimistic.
|
|
101
|
+
//
|
|
102
|
+
// High-frequency click; the perceived latency win matters. Four-step
|
|
103
|
+
// pattern per `docs/frontend/optimistic-updates.md`:
|
|
104
|
+
// 1. onMutate: cancel in-flight refetches, snapshot, patch.
|
|
105
|
+
// 2. onError: roll back from the snapshot, surface a toast.
|
|
106
|
+
// 3. onSettled: invalidate the exact key so the cache reconciles
|
|
107
|
+
// with server truth on both branches.
|
|
108
|
+
// 4. No success toast — the row fades; that IS the feedback.
|
|
64
109
|
const markAsReadMutation = notificationClient.markAsRead.useMutation({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
onMutate: async ({ notificationId }) => {
|
|
111
|
+
await queryClient.cancelQueries({ queryKey: notificationsQueryKey });
|
|
112
|
+
const previous =
|
|
113
|
+
queryClient.getQueryData<NotificationsQueryData>(notificationsQueryKey);
|
|
114
|
+
if (previous) {
|
|
115
|
+
queryClient.setQueryData<NotificationsQueryData>(
|
|
116
|
+
notificationsQueryKey,
|
|
117
|
+
{
|
|
118
|
+
...previous,
|
|
119
|
+
items: previous.items.map((n) =>
|
|
120
|
+
notificationId === undefined || n.id === notificationId
|
|
121
|
+
? { ...n, isRead: true }
|
|
122
|
+
: n,
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return { previous };
|
|
68
128
|
},
|
|
69
|
-
onError: (error) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
129
|
+
onError: (error, _vars, ctx) => {
|
|
130
|
+
if (ctx?.previous) {
|
|
131
|
+
queryClient.setQueryData(notificationsQueryKey, ctx.previous);
|
|
132
|
+
}
|
|
133
|
+
toastError(toast, "Failed to mark as read", error);
|
|
134
|
+
},
|
|
135
|
+
onSettled: () => {
|
|
136
|
+
void queryClient.invalidateQueries({ queryKey: notificationsQueryKey });
|
|
73
137
|
},
|
|
74
138
|
});
|
|
75
139
|
|
|
@@ -149,7 +213,7 @@ export const NotificationsPage = () => {
|
|
|
149
213
|
};
|
|
150
214
|
|
|
151
215
|
return (
|
|
152
|
-
<PageLayout title="Notifications" icon={Bell}
|
|
216
|
+
<PageLayout title="Notifications" icon={Bell}>
|
|
153
217
|
<div className="space-y-4">
|
|
154
218
|
{/* Header with filters */}
|
|
155
219
|
<div className="flex items-center justify-between">
|
|
@@ -204,11 +268,40 @@ export const NotificationsPage = () => {
|
|
|
204
268
|
</div>
|
|
205
269
|
|
|
206
270
|
{/* Notifications list */}
|
|
207
|
-
{
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
271
|
+
{loading ? (
|
|
272
|
+
<div className="space-y-2">
|
|
273
|
+
{Array.from({ length: 3 }, (_, index) => (
|
|
274
|
+
<Card key={index} className="p-4">
|
|
275
|
+
<div className="flex items-start justify-between gap-4">
|
|
276
|
+
<div className="flex-1 min-w-0 space-y-2">
|
|
277
|
+
<div className="flex items-center gap-2">
|
|
278
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
279
|
+
<Skeleton className="h-3 w-20" />
|
|
280
|
+
</div>
|
|
281
|
+
<Skeleton className="h-5 w-2/3" />
|
|
282
|
+
<Skeleton className="h-4 w-full" />
|
|
283
|
+
<Skeleton className="h-4 w-1/2" />
|
|
284
|
+
</div>
|
|
285
|
+
<div className="flex items-center gap-1">
|
|
286
|
+
<Skeleton className="h-8 w-8" />
|
|
287
|
+
<Skeleton className="h-8 w-8" />
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</Card>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
) : notificationsQuery.isError ? (
|
|
294
|
+
<QueryErrorState
|
|
295
|
+
error={notificationsQuery.error}
|
|
296
|
+
onRetry={() => void notificationsQuery.refetch()}
|
|
297
|
+
resource="notifications"
|
|
298
|
+
/>
|
|
299
|
+
) : notifications.length === 0 ? (
|
|
300
|
+
<ListEmptyState
|
|
301
|
+
resource="notifications"
|
|
302
|
+
description="You're all caught up. New notifications about systems you're subscribed to will show up here."
|
|
303
|
+
icon={<Bell className="h-10 w-10" />}
|
|
304
|
+
/>
|
|
212
305
|
) : (
|
|
213
306
|
<div className="space-y-2">
|
|
214
307
|
{groupByCollapseKey(notifications).map((group) => {
|