@agent-native/core 0.84.67 → 0.85.1

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.
Files changed (149) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +55 -0
  4. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +55 -0
  5. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +55 -0
  6. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +55 -0
  7. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +55 -0
  8. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +55 -0
  9. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +55 -0
  10. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +55 -0
  11. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +55 -0
  12. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +55 -0
  13. package/corpus/core/docs/content/tracking.mdx +2 -0
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/agent/action-continuation-guidance.ts +1 -1
  16. package/corpus/core/src/agent/production-agent.ts +222 -38
  17. package/corpus/core/src/agent/run-manager.ts +79 -0
  18. package/corpus/core/src/agent/run-store.ts +122 -8
  19. package/corpus/core/src/agent/types.ts +13 -0
  20. package/corpus/core/src/client/RunStuckBanner.tsx +14 -0
  21. package/corpus/core/src/client/agent-chat-adapter.ts +475 -0
  22. package/corpus/core/src/client/analytics.ts +2 -0
  23. package/corpus/core/src/client/error-format.ts +4 -0
  24. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +1 -1
  25. package/corpus/core/src/client/session-replay.ts +774 -2
  26. package/corpus/core/src/client/sse-event-processor.ts +65 -9
  27. package/corpus/core/src/client/use-run-stuck-detection.ts +31 -7
  28. package/corpus/core/src/db/runtime-diagnostics.ts +331 -0
  29. package/corpus/core/src/server/agent-chat-plugin.ts +115 -2
  30. package/corpus/core/src/server/core-routes-plugin.ts +91 -4
  31. package/corpus/core/src/server/http-response-telemetry.ts +189 -0
  32. package/corpus/core/src/server/self-dispatch.ts +29 -0
  33. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +53 -1
  34. package/corpus/templates/analytics/AGENTS.md +13 -0
  35. package/corpus/templates/analytics/actions/view-screen.ts +25 -0
  36. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +20 -0
  37. package/corpus/templates/analytics/app/global.css +55 -0
  38. package/corpus/templates/analytics/app/i18n/zh-TW.ts +100 -1
  39. package/corpus/templates/analytics/app/i18n-data.ts +1226 -0
  40. package/corpus/templates/analytics/app/pages/Settings.tsx +3 -0
  41. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +182 -144
  42. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +11 -18
  43. package/corpus/templates/analytics/app/pages/analyses/AnalysisDetail.tsx +11 -18
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +49 -1
  45. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +557 -0
  46. package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +274 -0
  47. package/corpus/templates/analytics/app/pages/settings/AlertRulesSettingsCard.tsx +974 -0
  48. package/corpus/templates/analytics/changelog/2026-07-02-alert-rules-can-now-be-viewed-and-managed-from-settings.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-02-recent-ask-chats-show-a-loading-placeholder-while-your-chat-.md +6 -0
  50. package/corpus/templates/analytics/changelog/2026-07-02-session-replays-now-capture-console-logs-and-network-request.md +6 -0
  51. package/corpus/templates/analytics/changelog/2026-07-02-sharing-status-labels-use-neutral-icons-instead-of-colored-d.md +6 -0
  52. package/corpus/templates/analytics/changelog/2026-07-02-the-session-replay-viewer-has-a-dev-tools-panel-with-console.md +6 -0
  53. package/corpus/templates/analytics/server/db/schema.ts +3 -0
  54. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -0
  55. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +142 -1
  56. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +369 -8
  57. package/corpus/templates/analytics/server/lib/session-replay.ts +76 -2
  58. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +101 -0
  59. package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +78 -0
  60. package/corpus/templates/design/app/pages/DesignEditor.tsx +24 -12
  61. package/corpus/templates/design/changelog/2026-07-02-the-temporary-code-tab-is-hidden-from-the-design-editor-side.md +6 -0
  62. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  63. package/corpus/templates/plan/actions/update-local-plan-folder.ts +3 -1
  64. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  65. package/corpus/templates/plan/changelog/2026-07-02-trying-to-edit-a-recap-or-plan-you-can-only-view-now-explain.md +6 -0
  66. package/corpus/templates/plan/server/plans.ts +26 -9
  67. package/dist/agent/action-continuation-guidance.js +1 -1
  68. package/dist/agent/action-continuation-guidance.js.map +1 -1
  69. package/dist/agent/production-agent.d.ts +9 -1
  70. package/dist/agent/production-agent.d.ts.map +1 -1
  71. package/dist/agent/production-agent.js +168 -34
  72. package/dist/agent/production-agent.js.map +1 -1
  73. package/dist/agent/run-manager.d.ts +29 -0
  74. package/dist/agent/run-manager.d.ts.map +1 -1
  75. package/dist/agent/run-manager.js +73 -0
  76. package/dist/agent/run-manager.js.map +1 -1
  77. package/dist/agent/run-store.d.ts +41 -0
  78. package/dist/agent/run-store.d.ts.map +1 -1
  79. package/dist/agent/run-store.js +105 -7
  80. package/dist/agent/run-store.js.map +1 -1
  81. package/dist/agent/types.d.ts +13 -0
  82. package/dist/agent/types.d.ts.map +1 -1
  83. package/dist/agent/types.js.map +1 -1
  84. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  85. package/dist/client/RunStuckBanner.js +13 -0
  86. package/dist/client/RunStuckBanner.js.map +1 -1
  87. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  88. package/dist/client/agent-chat-adapter.js +410 -0
  89. package/dist/client/agent-chat-adapter.js.map +1 -1
  90. package/dist/client/analytics.d.ts +1 -1
  91. package/dist/client/analytics.d.ts.map +1 -1
  92. package/dist/client/analytics.js.map +1 -1
  93. package/dist/client/error-format.d.ts.map +1 -1
  94. package/dist/client/error-format.js +2 -0
  95. package/dist/client/error-format.js.map +1 -1
  96. package/dist/client/extensions/ExtensionsSidebarSection.js +1 -1
  97. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  98. package/dist/client/session-replay.d.ts +36 -0
  99. package/dist/client/session-replay.d.ts.map +1 -1
  100. package/dist/client/session-replay.js +579 -1
  101. package/dist/client/session-replay.js.map +1 -1
  102. package/dist/client/sse-event-processor.d.ts +20 -1
  103. package/dist/client/sse-event-processor.d.ts.map +1 -1
  104. package/dist/client/sse-event-processor.js +42 -11
  105. package/dist/client/sse-event-processor.js.map +1 -1
  106. package/dist/client/use-run-stuck-detection.d.ts +14 -4
  107. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  108. package/dist/client/use-run-stuck-detection.js +18 -4
  109. package/dist/client/use-run-stuck-detection.js.map +1 -1
  110. package/dist/collab/awareness.d.ts +2 -2
  111. package/dist/collab/awareness.d.ts.map +1 -1
  112. package/dist/collab/routes.d.ts +2 -2
  113. package/dist/db/runtime-diagnostics.d.ts +52 -0
  114. package/dist/db/runtime-diagnostics.d.ts.map +1 -0
  115. package/dist/db/runtime-diagnostics.js +250 -0
  116. package/dist/db/runtime-diagnostics.js.map +1 -0
  117. package/dist/notifications/routes.d.ts +1 -1
  118. package/dist/observability/routes.d.ts +3 -3
  119. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  120. package/dist/resources/handlers.d.ts +3 -3
  121. package/dist/secrets/routes.d.ts +3 -3
  122. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  123. package/dist/server/agent-chat-plugin.js +101 -2
  124. package/dist/server/agent-chat-plugin.js.map +1 -1
  125. package/dist/server/core-routes-plugin.d.ts +17 -0
  126. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  127. package/dist/server/core-routes-plugin.js +65 -5
  128. package/dist/server/core-routes-plugin.js.map +1 -1
  129. package/dist/server/http-response-telemetry.d.ts +4 -0
  130. package/dist/server/http-response-telemetry.d.ts.map +1 -0
  131. package/dist/server/http-response-telemetry.js +174 -0
  132. package/dist/server/http-response-telemetry.js.map +1 -0
  133. package/dist/server/self-dispatch.d.ts +17 -0
  134. package/dist/server/self-dispatch.d.ts.map +1 -1
  135. package/dist/server/self-dispatch.js +11 -0
  136. package/dist/server/self-dispatch.js.map +1 -1
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/docs/content/locales/ar-SA/tracking.mdx +55 -0
  139. package/docs/content/locales/de-DE/tracking.mdx +55 -0
  140. package/docs/content/locales/es-ES/tracking.mdx +55 -0
  141. package/docs/content/locales/fr-FR/tracking.mdx +55 -0
  142. package/docs/content/locales/hi-IN/tracking.mdx +55 -0
  143. package/docs/content/locales/ja-JP/tracking.mdx +55 -0
  144. package/docs/content/locales/ko-KR/tracking.mdx +55 -0
  145. package/docs/content/locales/pt-BR/tracking.mdx +55 -0
  146. package/docs/content/locales/zh-CN/tracking.mdx +55 -0
  147. package/docs/content/locales/zh-TW/tracking.mdx +55 -0
  148. package/docs/content/tracking.mdx +2 -0
  149. package/package.json +1 -1
@@ -0,0 +1,974 @@
1
+ import {
2
+ useActionMutation,
3
+ useActionQuery,
4
+ useT,
5
+ } from "@agent-native/core/client";
6
+ import {
7
+ IconBell,
8
+ IconBellRinging,
9
+ IconLoader2,
10
+ IconPencil,
11
+ IconPlayerPlay,
12
+ IconPlus,
13
+ IconTrash,
14
+ } from "@tabler/icons-react";
15
+ import { useQueryClient } from "@tanstack/react-query";
16
+ import { useMemo, useState } from "react";
17
+ import { toast } from "sonner";
18
+
19
+ import {
20
+ AlertDialog,
21
+ AlertDialogAction,
22
+ AlertDialogCancel,
23
+ AlertDialogContent,
24
+ AlertDialogDescription,
25
+ AlertDialogFooter,
26
+ AlertDialogHeader,
27
+ AlertDialogTitle,
28
+ } from "@/components/ui/alert-dialog";
29
+ import { Badge } from "@/components/ui/badge";
30
+ import { Button } from "@/components/ui/button";
31
+ import {
32
+ Card,
33
+ CardContent,
34
+ CardDescription,
35
+ CardHeader,
36
+ CardTitle,
37
+ } from "@/components/ui/card";
38
+ import { Checkbox } from "@/components/ui/checkbox";
39
+ import {
40
+ Dialog,
41
+ DialogContent,
42
+ DialogDescription,
43
+ DialogFooter,
44
+ DialogHeader,
45
+ DialogTitle,
46
+ } from "@/components/ui/dialog";
47
+ import { Input } from "@/components/ui/input";
48
+ import { Label } from "@/components/ui/label";
49
+ import {
50
+ Select,
51
+ SelectContent,
52
+ SelectItem,
53
+ SelectTrigger,
54
+ SelectValue,
55
+ } from "@/components/ui/select";
56
+ import { Switch } from "@/components/ui/switch";
57
+ import {
58
+ Table,
59
+ TableBody,
60
+ TableCell,
61
+ TableHead,
62
+ TableHeader,
63
+ TableRow,
64
+ } from "@/components/ui/table";
65
+ import { Textarea } from "@/components/ui/textarea";
66
+ import { cn } from "@/lib/utils";
67
+
68
+ type AlertFilterOp = "equals" | "not_equals" | "contains" | "in" | "exists";
69
+
70
+ interface AnalyticsAlertFilter {
71
+ field: string;
72
+ op?: AlertFilterOp;
73
+ value?: unknown;
74
+ }
75
+
76
+ type AlertThresholdMode = "event_count" | "distinct_count";
77
+ type AlertSeverity = "warning" | "critical";
78
+ type AlertStatus = "ok" | "triggered" | "cooldown" | "error" | "running";
79
+ type KnownChannel = "inbox" | "email" | "slack" | "webhook";
80
+
81
+ interface AnalyticsAlertRule {
82
+ id: string;
83
+ name: string;
84
+ description: string;
85
+ eventName: string | null;
86
+ filters: AnalyticsAlertFilter[];
87
+ thresholdMode: AlertThresholdMode;
88
+ distinctBy: string | null;
89
+ threshold: number;
90
+ windowMinutes: number;
91
+ cooldownMinutes: number;
92
+ severity: AlertSeverity;
93
+ channels: string[];
94
+ emailRecipients: string[];
95
+ enabled: boolean;
96
+ lastEvaluatedAt: string | null;
97
+ lastTriggeredAt: string | null;
98
+ lastStatus: AlertStatus | null;
99
+ lastError: string | null;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+
104
+ interface RunAlertsResult {
105
+ processed: number;
106
+ triggered: number;
107
+ failed: number;
108
+ remaining: number;
109
+ }
110
+
111
+ interface AlertRuleFormState {
112
+ id?: string;
113
+ name: string;
114
+ description: string;
115
+ eventName: string;
116
+ filtersJson: string;
117
+ thresholdMode: AlertThresholdMode;
118
+ distinctBy: string;
119
+ threshold: string;
120
+ windowMinutes: string;
121
+ cooldownMinutes: string;
122
+ severity: AlertSeverity;
123
+ enabled: boolean;
124
+ channels: Record<KnownChannel, boolean>;
125
+ customChannels: string;
126
+ emailRecipients: string;
127
+ }
128
+
129
+ const KNOWN_CHANNELS = ["inbox", "email", "slack", "webhook"] as const;
130
+
131
+ function emptyAlertForm(): AlertRuleFormState {
132
+ return {
133
+ name: "",
134
+ description: "",
135
+ eventName: "",
136
+ filtersJson: "[]",
137
+ thresholdMode: "event_count",
138
+ distinctBy: "user_key",
139
+ threshold: "5",
140
+ windowMinutes: "10",
141
+ cooldownMinutes: "30",
142
+ severity: "warning",
143
+ enabled: true,
144
+ channels: {
145
+ inbox: true,
146
+ email: false,
147
+ slack: false,
148
+ webhook: false,
149
+ },
150
+ customChannels: "",
151
+ emailRecipients: "",
152
+ };
153
+ }
154
+
155
+ function formFromRule(rule: AnalyticsAlertRule): AlertRuleFormState {
156
+ const channelSet = new Set(rule.channels);
157
+ const customChannels = rule.channels.filter(
158
+ (channel): channel is string =>
159
+ !KNOWN_CHANNELS.includes(channel as KnownChannel),
160
+ );
161
+
162
+ return {
163
+ id: rule.id,
164
+ name: rule.name,
165
+ description: rule.description,
166
+ eventName: rule.eventName ?? "",
167
+ filtersJson: JSON.stringify(rule.filters ?? [], null, 2),
168
+ thresholdMode: rule.thresholdMode,
169
+ distinctBy: rule.distinctBy ?? "user_key",
170
+ threshold: String(rule.threshold),
171
+ windowMinutes: String(rule.windowMinutes),
172
+ cooldownMinutes: String(rule.cooldownMinutes),
173
+ severity: rule.severity,
174
+ enabled: rule.enabled,
175
+ channels: {
176
+ inbox: channelSet.has("inbox"),
177
+ email: channelSet.has("email"),
178
+ slack: channelSet.has("slack"),
179
+ webhook: channelSet.has("webhook"),
180
+ },
181
+ customChannels: customChannels.join(", "),
182
+ emailRecipients: rule.emailRecipients.join("\n"),
183
+ };
184
+ }
185
+
186
+ function splitList(value: string): string[] {
187
+ return value
188
+ .split(/[\n,]+/)
189
+ .map((item) => item.trim())
190
+ .filter(Boolean);
191
+ }
192
+
193
+ function parseInteger(value: string, fallback: number): number {
194
+ const parsed = Number.parseInt(value, 10);
195
+ return Number.isFinite(parsed) ? parsed : fallback;
196
+ }
197
+
198
+ function formatDate(value: string | null, fallback: string): string {
199
+ if (!value) return fallback;
200
+ const date = new Date(value);
201
+ if (Number.isNaN(date.getTime())) return fallback;
202
+ return date.toLocaleString(undefined, {
203
+ month: "short",
204
+ day: "numeric",
205
+ hour: "numeric",
206
+ minute: "2-digit",
207
+ });
208
+ }
209
+
210
+ function formatWindow(minutes: number): string {
211
+ if (minutes < 60) return `${minutes}m`;
212
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
213
+ return `${minutes}m`;
214
+ }
215
+
216
+ function channelsFromForm(form: AlertRuleFormState): string[] {
217
+ const selected = KNOWN_CHANNELS.filter((channel) => form.channels[channel]);
218
+ const custom = splitList(form.customChannels);
219
+ return [...selected, ...custom];
220
+ }
221
+
222
+ function payloadFromRule(
223
+ rule: AnalyticsAlertRule,
224
+ overrides: Partial<AnalyticsAlertRule> = {},
225
+ ) {
226
+ return {
227
+ id: rule.id,
228
+ name: rule.name,
229
+ description: rule.description,
230
+ eventName: rule.eventName,
231
+ filters: rule.filters,
232
+ thresholdMode: rule.thresholdMode,
233
+ distinctBy: rule.distinctBy,
234
+ threshold: rule.threshold,
235
+ windowMinutes: rule.windowMinutes,
236
+ cooldownMinutes: rule.cooldownMinutes,
237
+ severity: rule.severity,
238
+ channels: rule.channels,
239
+ emailRecipients: rule.emailRecipients,
240
+ enabled: rule.enabled,
241
+ ...overrides,
242
+ };
243
+ }
244
+
245
+ function parseFilters(filtersJson: string): AnalyticsAlertFilter[] {
246
+ const trimmed = filtersJson.trim();
247
+ if (!trimmed) return [];
248
+ const parsed = JSON.parse(trimmed) as unknown;
249
+ if (!Array.isArray(parsed)) {
250
+ throw new Error("filters must be a JSON array");
251
+ }
252
+ return parsed as AnalyticsAlertFilter[];
253
+ }
254
+
255
+ export function AlertRulesSettingsCard() {
256
+ const t = useT();
257
+ const queryClient = useQueryClient();
258
+ const [editing, setEditing] = useState<AlertRuleFormState | null>(null);
259
+ const [ruleToDelete, setRuleToDelete] = useState<AnalyticsAlertRule | null>(
260
+ null,
261
+ );
262
+
263
+ const { data, isLoading, refetch } = useActionQuery<AnalyticsAlertRule[]>(
264
+ "list-analytics-alert-rules",
265
+ undefined,
266
+ { staleTime: 10_000 },
267
+ );
268
+ const saveRule = useActionMutation("save-analytics-alert-rule");
269
+ const deleteRule = useActionMutation("delete-analytics-alert-rule");
270
+ const runAlerts = useActionMutation("run-analytics-alerts");
271
+
272
+ const rules = useMemo(() => (Array.isArray(data) ? data : []), [data]);
273
+ const enabledCount = rules.filter((rule) => rule.enabled).length;
274
+
275
+ async function refreshAlerts() {
276
+ await queryClient.invalidateQueries({
277
+ queryKey: ["action", "list-analytics-alert-rules"],
278
+ });
279
+ await refetch();
280
+ }
281
+
282
+ async function handleSave() {
283
+ if (!editing) return;
284
+ let filters: AnalyticsAlertFilter[];
285
+ try {
286
+ filters = parseFilters(editing.filtersJson);
287
+ } catch (err) {
288
+ toast.error(
289
+ t("settings.alertFiltersInvalid", {
290
+ message: err instanceof Error ? err.message : String(err),
291
+ }),
292
+ );
293
+ return;
294
+ }
295
+
296
+ const name = editing.name.trim();
297
+ if (!name) {
298
+ toast.error(t("settings.alertNameRequired"));
299
+ return;
300
+ }
301
+
302
+ const channels = channelsFromForm(editing);
303
+ if (channels.length === 0) {
304
+ toast.error(t("settings.alertChannelRequired"));
305
+ return;
306
+ }
307
+
308
+ try {
309
+ await saveRule.mutateAsync({
310
+ id: editing.id,
311
+ name,
312
+ description: editing.description.trim(),
313
+ eventName: editing.eventName.trim() || null,
314
+ filters,
315
+ thresholdMode: editing.thresholdMode,
316
+ distinctBy:
317
+ editing.thresholdMode === "distinct_count"
318
+ ? editing.distinctBy.trim() || "user_key"
319
+ : editing.distinctBy.trim() || null,
320
+ threshold: parseInteger(editing.threshold, 1),
321
+ windowMinutes: parseInteger(editing.windowMinutes, 10),
322
+ cooldownMinutes: parseInteger(editing.cooldownMinutes, 30),
323
+ severity: editing.severity,
324
+ channels,
325
+ emailRecipients: splitList(editing.emailRecipients),
326
+ enabled: editing.enabled,
327
+ });
328
+ setEditing(null);
329
+ await refreshAlerts();
330
+ toast.success(t("settings.alertSaved"));
331
+ } catch (err) {
332
+ toast.error(
333
+ t("settings.alertSaveFailed", {
334
+ message: err instanceof Error ? err.message : String(err),
335
+ }),
336
+ );
337
+ }
338
+ }
339
+
340
+ async function handleToggle(rule: AnalyticsAlertRule, enabled: boolean) {
341
+ try {
342
+ await saveRule.mutateAsync(payloadFromRule(rule, { enabled }));
343
+ await refreshAlerts();
344
+ toast.success(
345
+ enabled
346
+ ? t("settings.alertEnabledToast")
347
+ : t("settings.alertDisabledToast"),
348
+ );
349
+ } catch (err) {
350
+ toast.error(
351
+ t("settings.alertSaveFailed", {
352
+ message: err instanceof Error ? err.message : String(err),
353
+ }),
354
+ );
355
+ }
356
+ }
357
+
358
+ async function handleDelete() {
359
+ if (!ruleToDelete) return;
360
+ try {
361
+ await deleteRule.mutateAsync({ id: ruleToDelete.id });
362
+ setRuleToDelete(null);
363
+ await refreshAlerts();
364
+ toast.success(t("settings.alertDeleted"));
365
+ } catch (err) {
366
+ toast.error(
367
+ t("settings.alertDeleteFailed", {
368
+ message: err instanceof Error ? err.message : String(err),
369
+ }),
370
+ );
371
+ }
372
+ }
373
+
374
+ async function handleRunAlerts() {
375
+ try {
376
+ const result = (await runAlerts.mutateAsync({
377
+ limit: 200,
378
+ })) as RunAlertsResult;
379
+ await refreshAlerts();
380
+ toast.success(
381
+ t("settings.alertRunComplete", {
382
+ processed: result.processed,
383
+ triggered: result.triggered,
384
+ failed: result.failed,
385
+ }),
386
+ );
387
+ } catch (err) {
388
+ toast.error(
389
+ t("settings.alertRunFailed", {
390
+ message: err instanceof Error ? err.message : String(err),
391
+ }),
392
+ );
393
+ }
394
+ }
395
+
396
+ return (
397
+ <>
398
+ <Card id="alert-rules" className="bg-card border-border/50 scroll-mt-16">
399
+ <CardHeader>
400
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
401
+ <div className="min-w-0">
402
+ <CardTitle className="flex items-center gap-2 text-base">
403
+ <IconBell className="size-4 text-primary" />
404
+ {t("settings.alertsTitle")}
405
+ </CardTitle>
406
+ <CardDescription>
407
+ {t("settings.alertsDescription")}
408
+ </CardDescription>
409
+ </div>
410
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
411
+ <Button
412
+ type="button"
413
+ variant="outline"
414
+ size="sm"
415
+ onClick={handleRunAlerts}
416
+ disabled={runAlerts.isPending || enabledCount === 0}
417
+ >
418
+ {runAlerts.isPending ? (
419
+ <IconLoader2 className="size-3.5 animate-spin" />
420
+ ) : (
421
+ <IconPlayerPlay className="size-3.5" />
422
+ )}
423
+ {t("settings.alertRunNow")}
424
+ </Button>
425
+ <Button
426
+ type="button"
427
+ size="sm"
428
+ onClick={() => setEditing(emptyAlertForm())}
429
+ >
430
+ <IconPlus className="size-3.5" />
431
+ {t("settings.alertNew")}
432
+ </Button>
433
+ </div>
434
+ </div>
435
+ </CardHeader>
436
+ <CardContent>
437
+ {isLoading ? (
438
+ <div className="space-y-2">
439
+ {[0, 1, 2].map((item) => (
440
+ <div
441
+ key={item}
442
+ className="h-14 rounded-md border border-border bg-muted/30"
443
+ />
444
+ ))}
445
+ </div>
446
+ ) : rules.length === 0 ? (
447
+ <div className="rounded-md border border-dashed px-3 py-6 text-center">
448
+ <p className="text-sm font-medium">
449
+ {t("settings.alertsEmptyTitle")}
450
+ </p>
451
+ <p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
452
+ {t("settings.alertsEmptyDescription")}
453
+ </p>
454
+ <Button
455
+ type="button"
456
+ size="sm"
457
+ className="mt-4"
458
+ onClick={() => setEditing(emptyAlertForm())}
459
+ >
460
+ <IconPlus className="size-3.5" />
461
+ {t("settings.alertNew")}
462
+ </Button>
463
+ </div>
464
+ ) : (
465
+ <Table>
466
+ <TableHeader>
467
+ <TableRow>
468
+ <TableHead>{t("settings.alertRuleColumn")}</TableHead>
469
+ <TableHead>{t("settings.alertConditionColumn")}</TableHead>
470
+ <TableHead>{t("settings.alertDeliveryColumn")}</TableHead>
471
+ <TableHead>{t("settings.alertStatusColumn")}</TableHead>
472
+ <TableHead className="w-[116px] text-end">
473
+ {t("settings.alertActionsColumn")}
474
+ </TableHead>
475
+ </TableRow>
476
+ </TableHeader>
477
+ <TableBody>
478
+ {rules.map((rule) => (
479
+ <TableRow key={rule.id}>
480
+ <TableCell className="min-w-[190px]">
481
+ <div className="flex items-start gap-3">
482
+ <Switch
483
+ checked={rule.enabled}
484
+ onCheckedChange={(enabled) =>
485
+ void handleToggle(rule, enabled)
486
+ }
487
+ aria-label={t("settings.alertToggleLabel", {
488
+ name: rule.name,
489
+ })}
490
+ />
491
+ <div className="min-w-0">
492
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
493
+ <span className="truncate font-medium">
494
+ {rule.name}
495
+ </span>
496
+ <Badge
497
+ variant={
498
+ rule.severity === "critical"
499
+ ? "destructive"
500
+ : "outline"
501
+ }
502
+ className="shrink-0 text-[10px]"
503
+ >
504
+ {rule.severity === "critical"
505
+ ? t("settings.alertSeverityCritical")
506
+ : t("settings.alertSeverityWarning")}
507
+ </Badge>
508
+ </div>
509
+ {rule.description ? (
510
+ <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
511
+ {rule.description}
512
+ </p>
513
+ ) : null}
514
+ </div>
515
+ </div>
516
+ </TableCell>
517
+ <TableCell className="min-w-[180px] text-xs">
518
+ <div className="font-medium">
519
+ {formatThreshold(rule, t)}
520
+ </div>
521
+ <div className="mt-1 text-muted-foreground">
522
+ {formatScope(rule, t)}
523
+ </div>
524
+ </TableCell>
525
+ <TableCell className="min-w-[140px] text-xs">
526
+ <div className="flex flex-wrap gap-1">
527
+ {rule.channels.map((channel) => (
528
+ <Badge
529
+ key={channel}
530
+ variant="secondary"
531
+ className="text-[10px]"
532
+ >
533
+ {knownChannelLabel(channel, t)}
534
+ </Badge>
535
+ ))}
536
+ </div>
537
+ {rule.emailRecipients.length > 0 ? (
538
+ <div className="mt-1 truncate text-muted-foreground">
539
+ {rule.emailRecipients.join(", ")}
540
+ </div>
541
+ ) : null}
542
+ </TableCell>
543
+ <TableCell className="min-w-[150px] text-xs">
544
+ <div className="flex items-center gap-2">
545
+ <span
546
+ className={cn(
547
+ "size-2 rounded-full",
548
+ statusDotClass(rule.lastStatus),
549
+ )}
550
+ />
551
+ <span className="font-medium">
552
+ {statusLabel(rule.lastStatus, t)}
553
+ </span>
554
+ </div>
555
+ <div className="mt-1 text-muted-foreground">
556
+ {t("settings.alertLastChecked", {
557
+ date: formatDate(
558
+ rule.lastEvaluatedAt,
559
+ t("settings.alertNever"),
560
+ ),
561
+ })}
562
+ </div>
563
+ {rule.lastError ? (
564
+ <div className="mt-1 max-w-[220px] truncate text-destructive">
565
+ {rule.lastError}
566
+ </div>
567
+ ) : null}
568
+ </TableCell>
569
+ <TableCell>
570
+ <div className="flex justify-end gap-1">
571
+ <Button
572
+ type="button"
573
+ variant="ghost"
574
+ size="sm"
575
+ onClick={() => setEditing(formFromRule(rule))}
576
+ aria-label={t("settings.alertEditLabel", {
577
+ name: rule.name,
578
+ })}
579
+ >
580
+ <IconPencil className="size-3.5" />
581
+ </Button>
582
+ <Button
583
+ type="button"
584
+ variant="ghost"
585
+ size="sm"
586
+ className="text-destructive hover:text-destructive"
587
+ onClick={() => setRuleToDelete(rule)}
588
+ aria-label={t("settings.alertDeleteLabel", {
589
+ name: rule.name,
590
+ })}
591
+ >
592
+ <IconTrash className="size-3.5" />
593
+ </Button>
594
+ </div>
595
+ </TableCell>
596
+ </TableRow>
597
+ ))}
598
+ </TableBody>
599
+ </Table>
600
+ )}
601
+ </CardContent>
602
+ </Card>
603
+
604
+ <AlertRuleDialog
605
+ form={editing}
606
+ setForm={setEditing}
607
+ onSave={handleSave}
608
+ saving={saveRule.isPending}
609
+ />
610
+
611
+ <AlertDialog
612
+ open={!!ruleToDelete}
613
+ onOpenChange={(open) => !open && setRuleToDelete(null)}
614
+ >
615
+ <AlertDialogContent>
616
+ <AlertDialogHeader>
617
+ <AlertDialogTitle>
618
+ {t("settings.alertDeleteTitle")}
619
+ </AlertDialogTitle>
620
+ <AlertDialogDescription>
621
+ {t("settings.alertDeleteDescription", {
622
+ name: ruleToDelete?.name ?? "",
623
+ })}
624
+ </AlertDialogDescription>
625
+ </AlertDialogHeader>
626
+ <AlertDialogFooter>
627
+ <AlertDialogCancel>{t("sidebar.cancel")}</AlertDialogCancel>
628
+ <AlertDialogAction
629
+ onClick={() => void handleDelete()}
630
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
631
+ disabled={deleteRule.isPending}
632
+ >
633
+ {deleteRule.isPending && (
634
+ <IconLoader2 className="size-3.5 animate-spin" />
635
+ )}
636
+ {t("sidebar.delete")}
637
+ </AlertDialogAction>
638
+ </AlertDialogFooter>
639
+ </AlertDialogContent>
640
+ </AlertDialog>
641
+ </>
642
+ );
643
+ }
644
+
645
+ function AlertRuleDialog({
646
+ form,
647
+ setForm,
648
+ onSave,
649
+ saving,
650
+ }: {
651
+ form: AlertRuleFormState | null;
652
+ setForm: (form: AlertRuleFormState | null) => void;
653
+ onSave: () => void;
654
+ saving: boolean;
655
+ }) {
656
+ const t = useT();
657
+
658
+ if (!form) return null;
659
+
660
+ const setField = <K extends keyof AlertRuleFormState>(
661
+ key: K,
662
+ value: AlertRuleFormState[K],
663
+ ) => {
664
+ setForm({ ...form, [key]: value });
665
+ };
666
+
667
+ const setChannel = (channel: KnownChannel, checked: boolean) => {
668
+ setField("channels", {
669
+ ...form.channels,
670
+ [channel]: checked,
671
+ });
672
+ };
673
+
674
+ return (
675
+ <Dialog open={!!form} onOpenChange={(open) => !open && setForm(null)}>
676
+ <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[680px]">
677
+ <DialogHeader>
678
+ <DialogTitle>
679
+ {form.id ? t("settings.alertEdit") : t("settings.alertCreate")}
680
+ </DialogTitle>
681
+ <DialogDescription>
682
+ {t("settings.alertDialogDescription")}
683
+ </DialogDescription>
684
+ </DialogHeader>
685
+
686
+ <div className="grid gap-4 sm:grid-cols-2">
687
+ <div className="space-y-1.5 sm:col-span-2">
688
+ <Label htmlFor="alert-name">{t("settings.alertName")}</Label>
689
+ <Input
690
+ id="alert-name"
691
+ value={form.name}
692
+ onChange={(event) => setField("name", event.target.value)}
693
+ placeholder={t("settings.alertNamePlaceholder")}
694
+ />
695
+ </div>
696
+
697
+ <div className="space-y-1.5 sm:col-span-2">
698
+ <Label htmlFor="alert-description">
699
+ {t("settings.alertDescription")}
700
+ </Label>
701
+ <Textarea
702
+ id="alert-description"
703
+ value={form.description}
704
+ onChange={(event) => setField("description", event.target.value)}
705
+ className="min-h-[72px]"
706
+ />
707
+ </div>
708
+
709
+ <div className="space-y-1.5">
710
+ <Label htmlFor="alert-event-name">
711
+ {t("settings.alertEventName")}
712
+ </Label>
713
+ <Input
714
+ id="alert-event-name"
715
+ value={form.eventName}
716
+ onChange={(event) => setField("eventName", event.target.value)}
717
+ placeholder={t("settings.alertEventNamePlaceholder")}
718
+ />
719
+ </div>
720
+
721
+ <div className="space-y-1.5">
722
+ <Label htmlFor="alert-severity">
723
+ {t("settings.alertSeverity")}
724
+ </Label>
725
+ <Select
726
+ value={form.severity}
727
+ onValueChange={(value) =>
728
+ setField("severity", value as AlertSeverity)
729
+ }
730
+ >
731
+ <SelectTrigger id="alert-severity">
732
+ <SelectValue />
733
+ </SelectTrigger>
734
+ <SelectContent>
735
+ <SelectItem value="warning">
736
+ {t("settings.alertSeverityWarning")}
737
+ </SelectItem>
738
+ <SelectItem value="critical">
739
+ {t("settings.alertSeverityCritical")}
740
+ </SelectItem>
741
+ </SelectContent>
742
+ </Select>
743
+ </div>
744
+
745
+ <div className="space-y-1.5">
746
+ <Label htmlFor="alert-threshold-mode">
747
+ {t("settings.alertThresholdMode")}
748
+ </Label>
749
+ <Select
750
+ value={form.thresholdMode}
751
+ onValueChange={(value) =>
752
+ setField("thresholdMode", value as AlertThresholdMode)
753
+ }
754
+ >
755
+ <SelectTrigger id="alert-threshold-mode">
756
+ <SelectValue />
757
+ </SelectTrigger>
758
+ <SelectContent>
759
+ <SelectItem value="event_count">
760
+ {t("settings.alertModeEventCount")}
761
+ </SelectItem>
762
+ <SelectItem value="distinct_count">
763
+ {t("settings.alertModeDistinctCount")}
764
+ </SelectItem>
765
+ </SelectContent>
766
+ </Select>
767
+ </div>
768
+
769
+ <div className="space-y-1.5">
770
+ <Label htmlFor="alert-threshold">
771
+ {t("settings.alertThreshold")}
772
+ </Label>
773
+ <Input
774
+ id="alert-threshold"
775
+ inputMode="numeric"
776
+ value={form.threshold}
777
+ onChange={(event) => setField("threshold", event.target.value)}
778
+ />
779
+ </div>
780
+
781
+ {form.thresholdMode === "distinct_count" ? (
782
+ <div className="space-y-1.5 sm:col-span-2">
783
+ <Label htmlFor="alert-distinct-by">
784
+ {t("settings.alertDistinctBy")}
785
+ </Label>
786
+ <Input
787
+ id="alert-distinct-by"
788
+ value={form.distinctBy}
789
+ onChange={(event) => setField("distinctBy", event.target.value)}
790
+ placeholder="user_key"
791
+ />
792
+ </div>
793
+ ) : null}
794
+
795
+ <div className="space-y-1.5">
796
+ <Label htmlFor="alert-window">
797
+ {t("settings.alertWindowMinutes")}
798
+ </Label>
799
+ <Input
800
+ id="alert-window"
801
+ inputMode="numeric"
802
+ value={form.windowMinutes}
803
+ onChange={(event) =>
804
+ setField("windowMinutes", event.target.value)
805
+ }
806
+ />
807
+ </div>
808
+
809
+ <div className="space-y-1.5">
810
+ <Label htmlFor="alert-cooldown">
811
+ {t("settings.alertCooldownMinutes")}
812
+ </Label>
813
+ <Input
814
+ id="alert-cooldown"
815
+ inputMode="numeric"
816
+ value={form.cooldownMinutes}
817
+ onChange={(event) =>
818
+ setField("cooldownMinutes", event.target.value)
819
+ }
820
+ />
821
+ </div>
822
+
823
+ <div className="space-y-1.5 sm:col-span-2">
824
+ <Label htmlFor="alert-filters-json">
825
+ {t("settings.alertFilters")}
826
+ </Label>
827
+ <Textarea
828
+ id="alert-filters-json"
829
+ value={form.filtersJson}
830
+ onChange={(event) => setField("filtersJson", event.target.value)}
831
+ className="min-h-[104px] font-mono text-xs"
832
+ spellCheck={false}
833
+ />
834
+ </div>
835
+
836
+ <div className="space-y-2 sm:col-span-2">
837
+ <Label>{t("settings.alertChannels")}</Label>
838
+ <div className="grid gap-2 sm:grid-cols-4">
839
+ {KNOWN_CHANNELS.map((channel) => (
840
+ <label
841
+ key={channel}
842
+ className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
843
+ >
844
+ <Checkbox
845
+ checked={form.channels[channel]}
846
+ onCheckedChange={(checked) =>
847
+ setChannel(channel, checked === true)
848
+ }
849
+ />
850
+ {knownChannelLabel(channel, t)}
851
+ </label>
852
+ ))}
853
+ </div>
854
+ </div>
855
+
856
+ <div className="space-y-1.5">
857
+ <Label htmlFor="alert-custom-channels">
858
+ {t("settings.alertCustomChannels")}
859
+ </Label>
860
+ <Input
861
+ id="alert-custom-channels"
862
+ value={form.customChannels}
863
+ onChange={(event) =>
864
+ setField("customChannels", event.target.value)
865
+ }
866
+ placeholder={t("settings.alertCustomChannelsPlaceholder")}
867
+ />
868
+ </div>
869
+
870
+ <div className="space-y-1.5">
871
+ <Label htmlFor="alert-email-recipients">
872
+ {t("settings.alertEmailRecipients")}
873
+ </Label>
874
+ <Textarea
875
+ id="alert-email-recipients"
876
+ value={form.emailRecipients}
877
+ onChange={(event) =>
878
+ setField("emailRecipients", event.target.value)
879
+ }
880
+ className="min-h-[72px]"
881
+ placeholder={t("settings.alertEmailRecipientsPlaceholder")}
882
+ />
883
+ </div>
884
+
885
+ <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2 sm:col-span-2">
886
+ <div className="min-w-0">
887
+ <Label>{t("settings.alertEnabled")}</Label>
888
+ <p className="text-xs text-muted-foreground">
889
+ {t("settings.alertEnabledDescription")}
890
+ </p>
891
+ </div>
892
+ <Switch
893
+ checked={form.enabled}
894
+ onCheckedChange={(checked) => setField("enabled", checked)}
895
+ />
896
+ </div>
897
+ </div>
898
+
899
+ <DialogFooter>
900
+ <Button variant="outline" onClick={() => setForm(null)}>
901
+ {t("sidebar.cancel")}
902
+ </Button>
903
+ <Button onClick={onSave} disabled={saving}>
904
+ {saving && <IconLoader2 className="size-3.5 animate-spin" />}
905
+ {form.id ? t("settings.alertUpdate") : t("settings.alertSave")}
906
+ </Button>
907
+ </DialogFooter>
908
+ </DialogContent>
909
+ </Dialog>
910
+ );
911
+ }
912
+
913
+ function knownChannelLabel(
914
+ channel: string,
915
+ t: ReturnType<typeof useT>,
916
+ ): string {
917
+ if (channel === "inbox") return t("settings.alertChannelInbox");
918
+ if (channel === "email") return t("settings.alertChannelEmail");
919
+ if (channel === "slack") return t("settings.alertChannelSlack");
920
+ if (channel === "webhook") return t("settings.alertChannelWebhook");
921
+ return channel;
922
+ }
923
+
924
+ function statusLabel(
925
+ status: AlertStatus | null,
926
+ t: ReturnType<typeof useT>,
927
+ ): string {
928
+ if (status === "ok") return t("settings.alertStatusOk");
929
+ if (status === "triggered") return t("settings.alertStatusTriggered");
930
+ if (status === "cooldown") return t("settings.alertStatusCooldown");
931
+ if (status === "error") return t("settings.alertStatusError");
932
+ if (status === "running") return t("settings.alertStatusRunning");
933
+ return t("settings.alertStatusNever");
934
+ }
935
+
936
+ function statusDotClass(status: AlertStatus | null): string {
937
+ if (status === "triggered") return "bg-destructive";
938
+ if (status === "error") return "bg-destructive";
939
+ if (status === "running") return "bg-primary animate-pulse";
940
+ if (status === "cooldown") return "bg-amber-500";
941
+ if (status === "ok") return "bg-emerald-500";
942
+ return "bg-muted-foreground/40";
943
+ }
944
+
945
+ function formatScope(
946
+ rule: AnalyticsAlertRule,
947
+ t: ReturnType<typeof useT>,
948
+ ): string {
949
+ const filterCount = rule.filters.length;
950
+ const base = rule.eventName || t("settings.alertAllEvents");
951
+ if (filterCount === 0) return base;
952
+ return t("settings.alertScopeWithFilters", {
953
+ scope: base,
954
+ count: filterCount,
955
+ });
956
+ }
957
+
958
+ function formatThreshold(
959
+ rule: AnalyticsAlertRule,
960
+ t: ReturnType<typeof useT>,
961
+ ): string {
962
+ const window = formatWindow(rule.windowMinutes);
963
+ if (rule.thresholdMode === "distinct_count") {
964
+ return t("settings.alertDistinctThresholdSummary", {
965
+ count: rule.threshold,
966
+ field: rule.distinctBy || "user_key",
967
+ window,
968
+ });
969
+ }
970
+ return t("settings.alertEventThresholdSummary", {
971
+ count: rule.threshold,
972
+ window,
973
+ });
974
+ }