@sentry/junior-dashboard 0.232.0 → 0.234.0
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/dist/app.js +163 -46
- package/dist/client.js +55 -52
- package/dist/index.js +163 -46
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/client/App.tsx +359 -345
- package/src/client/api.ts +17 -1
- package/src/client/components/ActorAvatar.tsx +37 -0
- package/src/client/components/Button.tsx +12 -10
- package/src/client/components/Field.tsx +14 -1
- package/src/client/components/FormNotice.tsx +40 -0
- package/src/client/components/ParticipantAvatarStack.tsx +6 -14
- package/src/client/components/README.md +11 -2
- package/src/client/components/Select.tsx +18 -0
- package/src/client/components/StatusChip.tsx +15 -12
- package/src/client/components/TextInput.tsx +25 -5
- package/src/client/conversations/ConversationEventLog.tsx +49 -9
- package/src/client/conversations/EventDetails.tsx +94 -11
- package/src/client/conversations/EventUsage.tsx +118 -0
- package/src/client/conversations/eventLog.ts +59 -6
- package/src/client/conversations/eventTranscript.ts +2 -0
- package/src/client/conversations/transcriptBottomPinning.ts +2 -2
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +71 -54
- package/src/client/pages/automations/AutomationEditPage.tsx +172 -0
- package/src/client/pages/automations/AutomationEditor.tsx +397 -0
- package/src/client/pages/automations/AutomationEditorLoading.tsx +18 -0
- package/src/client/pages/automations/AutomationEventFields.tsx +264 -0
- package/src/client/pages/automations/AutomationFilters.tsx +171 -0
- package/src/client/pages/automations/AutomationFormSection.tsx +22 -0
- package/src/client/pages/automations/AutomationLifecycle.tsx +146 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +177 -0
- package/src/client/pages/automations/AutomationRow.tsx +358 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +157 -408
- package/src/client/pages/automations/AutomationsPageLayout.tsx +16 -5
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/dev/ComponentsPage.tsx +50 -4
- package/src/client/searchParams.ts +4 -2
- package/src/client.tsx +7 -4
- package/src/mock-reporting/automation-collection.ts +101 -0
- package/src/mock-reporting/fixtures.ts +82 -32
- package/src/mock-reporting/routes.ts +14 -9
|
@@ -11,7 +11,11 @@ import { SecondaryNavigation } from "../../components/layout/SecondaryNavigation
|
|
|
11
11
|
const RESERVED_TASK_SEGMENTS = new Set(["list", "runs"]);
|
|
12
12
|
|
|
13
13
|
function isTasksListPath(pathname: string): boolean {
|
|
14
|
-
if (
|
|
14
|
+
if (
|
|
15
|
+
pathname === "/automations/list" ||
|
|
16
|
+
/^\/automations\/(scheduled|event)\/[^/]+\/edit$/.test(pathname)
|
|
17
|
+
)
|
|
18
|
+
return true;
|
|
15
19
|
const match = pathname.match(/^\/automations\/([^/]+)$/);
|
|
16
20
|
return Boolean(match?.[1] && !RESERVED_TASK_SEGMENTS.has(match[1]));
|
|
17
21
|
}
|
|
@@ -22,14 +26,21 @@ const taskNavigationItems = [
|
|
|
22
26
|
{ label: "Runs", to: "/automations/runs" },
|
|
23
27
|
];
|
|
24
28
|
|
|
29
|
+
/** Keep Automation navigation consistent across lists, details, and editing. */
|
|
30
|
+
export function AutomationsNavigation() {
|
|
31
|
+
return (
|
|
32
|
+
<SecondaryNavigation
|
|
33
|
+
ariaLabel="Automations navigation"
|
|
34
|
+
items={taskNavigationItems}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/** Place the shared secondary navigation above one Automations page. */
|
|
26
40
|
export function AutomationsPageLayout(props: { children: ReactNode }) {
|
|
27
41
|
return (
|
|
28
42
|
<>
|
|
29
|
-
<
|
|
30
|
-
ariaLabel="Automations navigation"
|
|
31
|
-
items={taskNavigationItems}
|
|
32
|
-
/>
|
|
43
|
+
<AutomationsNavigation />
|
|
33
44
|
<PageLayout>{props.children}</PageLayout>
|
|
34
45
|
</>
|
|
35
46
|
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Automation editor
|
|
2
|
+
|
|
3
|
+
Core owns validation, creator access, revision checks, Schedule previews, and
|
|
4
|
+
the Event catalog. Preview requests cannot save or run work. Completed scheduled
|
|
5
|
+
Automations remain read-only.
|
|
6
|
+
|
|
7
|
+
The editor sends only changed fields. Keep unsupported triggers and stored
|
|
8
|
+
Destinations intact unless the user replaces them. Outcomes keep their order.
|
|
9
|
+
New messages can target only the current Destination or the creator.
|
|
10
|
+
|
|
11
|
+
Query refreshes must not replace an unsaved draft. After a stale save, the user
|
|
12
|
+
can keep their changed fields or load the latest saved version. Unchanged fields
|
|
13
|
+
come from that version; core checks its revision again on save.
|
|
14
|
+
|
|
15
|
+
The data router blocks navigation with unsaved changes. Browser unload uses the
|
|
16
|
+
native prompt. Save and Cancel keep the list query string.
|
|
17
|
+
|
|
18
|
+
Browser behavior lives in `e2e/automation-editor.spec.ts`. API and persistence
|
|
19
|
+
coverage lives in core's `tests/integration/api/automation-edits.test.ts`.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutomationEdit,
|
|
3
|
+
AutomationScheduleIntent,
|
|
4
|
+
AutomationUpdate,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
|
|
7
|
+
export const weekdays = [
|
|
8
|
+
"sunday",
|
|
9
|
+
"monday",
|
|
10
|
+
"tuesday",
|
|
11
|
+
"wednesday",
|
|
12
|
+
"thursday",
|
|
13
|
+
"friday",
|
|
14
|
+
"saturday",
|
|
15
|
+
] as const;
|
|
16
|
+
export type AutomationScheduleDraft =
|
|
17
|
+
| Extract<AutomationScheduleIntent, { kind: "recurring" }>
|
|
18
|
+
| {
|
|
19
|
+
kind: "one_off";
|
|
20
|
+
timezone: string;
|
|
21
|
+
timing: { type: "at"; date: string; time: string };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AutomationDraft = {
|
|
25
|
+
title: string;
|
|
26
|
+
instruction: string;
|
|
27
|
+
credentialMode: AutomationEdit["credentialMode"];
|
|
28
|
+
outcomes: NonNullable<AutomationUpdate["outcomes"]>;
|
|
29
|
+
schedule?: AutomationScheduleDraft;
|
|
30
|
+
trigger?: Extract<AutomationEdit, { kind: "event" }>["trigger"];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Keep stored values intact until a user edits that field. */
|
|
34
|
+
export function createAutomationDraft(value: AutomationEdit): AutomationDraft {
|
|
35
|
+
return {
|
|
36
|
+
title: value.title ?? "",
|
|
37
|
+
instruction: value.instruction,
|
|
38
|
+
credentialMode: value.credentialMode,
|
|
39
|
+
outcomes: value.outcomes,
|
|
40
|
+
trigger: value.kind === "event" ? value.trigger : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Populate calendar controls without changing the saved start date. */
|
|
45
|
+
export function scheduleDraft(
|
|
46
|
+
value: Extract<AutomationEdit, { kind: "scheduled" }>,
|
|
47
|
+
): AutomationScheduleDraft {
|
|
48
|
+
const recurrence = value.schedule.recurrence;
|
|
49
|
+
if (value.schedule.kind === "recurring" && recurrence) {
|
|
50
|
+
const { time, weekdays: days, ...fields } = recurrence;
|
|
51
|
+
return {
|
|
52
|
+
kind: "recurring",
|
|
53
|
+
...fields,
|
|
54
|
+
timezone: value.schedule.timezone,
|
|
55
|
+
time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
|
|
56
|
+
weekdays: days?.map((day) => weekdays[day]),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (value.nextRunAtMs) {
|
|
60
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
61
|
+
timeZone: value.schedule.timezone,
|
|
62
|
+
year: "numeric",
|
|
63
|
+
month: "2-digit",
|
|
64
|
+
day: "2-digit",
|
|
65
|
+
hour: "2-digit",
|
|
66
|
+
minute: "2-digit",
|
|
67
|
+
hourCycle: "h23",
|
|
68
|
+
}).formatToParts(value.nextRunAtMs);
|
|
69
|
+
const part = (name: string) =>
|
|
70
|
+
parts.find((p) => p.type === name)?.value ?? "";
|
|
71
|
+
return {
|
|
72
|
+
kind: "one_off",
|
|
73
|
+
timezone: value.schedule.timezone,
|
|
74
|
+
timing: {
|
|
75
|
+
type: "at",
|
|
76
|
+
date: `${part("year")}-${part("month")}-${part("day")}`,
|
|
77
|
+
time: `${part("hour")}:${part("minute")}`,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
kind: "recurring",
|
|
83
|
+
frequency: "daily",
|
|
84
|
+
time: "09:00",
|
|
85
|
+
timezone: value.schedule.timezone,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Keep metadata edits from replacing unchanged triggers or outcomes. */
|
|
90
|
+
export function automationDraftChanges(
|
|
91
|
+
original: AutomationEdit,
|
|
92
|
+
draft: AutomationDraft,
|
|
93
|
+
): Partial<AutomationDraft> {
|
|
94
|
+
const changes: Partial<AutomationDraft> = {};
|
|
95
|
+
if (draft.title !== (original.title ?? "")) changes.title = draft.title;
|
|
96
|
+
if (draft.instruction !== original.instruction)
|
|
97
|
+
changes.instruction = draft.instruction;
|
|
98
|
+
if (draft.credentialMode !== original.credentialMode)
|
|
99
|
+
changes.credentialMode = draft.credentialMode;
|
|
100
|
+
if (JSON.stringify(draft.outcomes) !== JSON.stringify(original.outcomes))
|
|
101
|
+
changes.outcomes = draft.outcomes;
|
|
102
|
+
if (original.kind === "scheduled" && draft.schedule)
|
|
103
|
+
changes.schedule = draft.schedule;
|
|
104
|
+
if (
|
|
105
|
+
original.kind === "event" &&
|
|
106
|
+
JSON.stringify(draft.trigger) !== JSON.stringify(original.trigger)
|
|
107
|
+
)
|
|
108
|
+
changes.trigger = draft.trigger;
|
|
109
|
+
return changes;
|
|
110
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AutomationSummary,
|
|
3
|
+
AutomationUpdate,
|
|
4
|
+
} from "@sentry/junior/api/schema";
|
|
5
|
+
|
|
6
|
+
type Outcome = NonNullable<AutomationUpdate["outcomes"]>[number];
|
|
7
|
+
|
|
8
|
+
/** Describe a retained Destination without guessing a recipient from a private id. */
|
|
9
|
+
export function automationOutcomeLabel(
|
|
10
|
+
outcome: Outcome,
|
|
11
|
+
destination: AutomationSummary["destination"],
|
|
12
|
+
): string {
|
|
13
|
+
if (typeof outcome.destination === "string")
|
|
14
|
+
return outcome.destination === "task_creator"
|
|
15
|
+
? "Creator · Direct message"
|
|
16
|
+
: `${destination.label} · ${destination.visibility}`;
|
|
17
|
+
if (
|
|
18
|
+
outcome.destination.channelId === destination.channelId &&
|
|
19
|
+
outcome.destination.teamId === destination.teamId
|
|
20
|
+
)
|
|
21
|
+
return `${destination.label} · ${destination.visibility}`;
|
|
22
|
+
return `Saved destination · ${outcome.destination.channelId}`;
|
|
23
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { FormNotice } from "../../components/FormNotice";
|
|
2
|
+
import { Select } from "../../components/Select";
|
|
1
3
|
import { ObjectFixtures } from "./ObjectFixtures";
|
|
2
4
|
import { ChartFixtures } from "./ChartFixtures";
|
|
3
5
|
import {
|
|
@@ -33,6 +35,7 @@ import { DashboardHeader } from "../../components/layout/DashboardHeader";
|
|
|
33
35
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
34
36
|
import { MetricList, MetricValue } from "../../components/Metric";
|
|
35
37
|
import { PageContentSkeleton } from "../../components/PageContentSkeleton";
|
|
38
|
+
import { ActorAvatar } from "../../components/ActorAvatar";
|
|
36
39
|
import { ParticipantAvatarStack } from "../../components/ParticipantAvatarStack";
|
|
37
40
|
import { StatCard } from "../../components/metrics/StatCard";
|
|
38
41
|
import { StatusChip } from "../../components/StatusChip";
|
|
@@ -308,6 +311,34 @@ function FoundationsGalleryPage() {
|
|
|
308
311
|
sectionId="foundations"
|
|
309
312
|
title="Foundations"
|
|
310
313
|
>
|
|
314
|
+
<Fixture title="Editor controls">
|
|
315
|
+
<Field
|
|
316
|
+
label="Instruction"
|
|
317
|
+
htmlFor="gallery-instruction"
|
|
318
|
+
error="Enter an instruction."
|
|
319
|
+
>
|
|
320
|
+
<TextArea
|
|
321
|
+
prose
|
|
322
|
+
id="gallery-instruction"
|
|
323
|
+
aria-invalid
|
|
324
|
+
aria-describedby="gallery-instruction-error"
|
|
325
|
+
/>
|
|
326
|
+
</Field>
|
|
327
|
+
<Field label="Repeat" htmlFor="gallery-repeat">
|
|
328
|
+
<TextInput
|
|
329
|
+
size="comfortable"
|
|
330
|
+
aria-label="Resource identifier"
|
|
331
|
+
defaultValue="getsentry/junior#42"
|
|
332
|
+
/>
|
|
333
|
+
<Select id="gallery-repeat">
|
|
334
|
+
<option>Every week</option>
|
|
335
|
+
</Select>
|
|
336
|
+
</Field>
|
|
337
|
+
<Button tone="primary">Save changes</Button>
|
|
338
|
+
<FormNotice title="This automation changed while you were editing.">
|
|
339
|
+
Your edits are still here. Review the latest version before saving.
|
|
340
|
+
</FormNotice>
|
|
341
|
+
</Fixture>
|
|
311
342
|
<Fixture title="Conversation navigation">
|
|
312
343
|
<DashboardChromeProvider>
|
|
313
344
|
<DashboardHeader
|
|
@@ -563,19 +594,34 @@ function FoundationsGalleryPage() {
|
|
|
563
594
|
/>
|
|
564
595
|
</div>
|
|
565
596
|
</Fixture>
|
|
566
|
-
<Fixture title="Status
|
|
567
|
-
<div className="flex flex-wrap items-center gap-
|
|
597
|
+
<Fixture title="Status labels">
|
|
598
|
+
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
|
568
599
|
<StatusChip tone="neutral">private</StatusChip>
|
|
569
600
|
<StatusChip tone="success">completed</StatusChip>
|
|
570
601
|
<StatusChip tone="danger">failed</StatusChip>
|
|
571
602
|
<StatusChip tone="warning">blocked</StatusChip>
|
|
572
603
|
<StatusChip tone="info">preference</StatusChip>
|
|
573
604
|
<StatusChip tone="accent">knowledge</StatusChip>
|
|
574
|
-
<StatusChip size="compact" tone="
|
|
575
|
-
|
|
605
|
+
<StatusChip size="compact" tone="warning">
|
|
606
|
+
Trigger unavailable
|
|
576
607
|
</StatusChip>
|
|
577
608
|
</div>
|
|
578
609
|
</Fixture>
|
|
610
|
+
<Fixture title="Actor avatars">
|
|
611
|
+
<div className="flex items-center gap-4">
|
|
612
|
+
<ActorAvatar
|
|
613
|
+
name="Ada Lovelace"
|
|
614
|
+
imageUrl="/_junior/dashboard/avatar.png"
|
|
615
|
+
size="detail"
|
|
616
|
+
/>
|
|
617
|
+
<ActorAvatar name="Grace Hopper" size="detail" />
|
|
618
|
+
<ActorAvatar
|
|
619
|
+
name="Alan Turing"
|
|
620
|
+
imageUrl="/missing-avatar.png"
|
|
621
|
+
size="list"
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
</Fixture>
|
|
579
625
|
<Fixture title="Participant avatars">
|
|
580
626
|
<div className="flex flex-wrap items-center gap-8">
|
|
581
627
|
<ParticipantAvatarStack
|
|
@@ -39,9 +39,10 @@ export function parseSearchParamEnum<T extends string>(
|
|
|
39
39
|
/** Read and write one debounced free-text query param (default `q`). */
|
|
40
40
|
export function useDebouncedSearchParam(
|
|
41
41
|
key = "q",
|
|
42
|
-
options: { delayMs?: number } = {},
|
|
42
|
+
options: { delayMs?: number; resetPage?: boolean } = {},
|
|
43
43
|
) {
|
|
44
44
|
const delayMs = options.delayMs ?? 250;
|
|
45
|
+
const resetPage = options.resetPage ?? false;
|
|
45
46
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
46
47
|
const committed = searchParams.get(key)?.trim() ?? "";
|
|
47
48
|
const [value, setValue] = useState(committed);
|
|
@@ -58,6 +59,7 @@ export function useDebouncedSearchParam(
|
|
|
58
59
|
setSearchParams(
|
|
59
60
|
(current) => {
|
|
60
61
|
const next = new URLSearchParams(current);
|
|
62
|
+
if (resetPage) next.delete("page");
|
|
61
63
|
if (normalized) next.set(key, normalized);
|
|
62
64
|
else next.delete(key);
|
|
63
65
|
return next;
|
|
@@ -66,7 +68,7 @@ export function useDebouncedSearchParam(
|
|
|
66
68
|
);
|
|
67
69
|
}, delayMs);
|
|
68
70
|
return () => window.clearTimeout(timeout);
|
|
69
|
-
}, [committed, delayMs, key, setSearchParams, value]);
|
|
71
|
+
}, [committed, delayMs, key, resetPage, setSearchParams, value]);
|
|
70
72
|
|
|
71
73
|
return [value, setValue, committed] as const;
|
|
72
74
|
}
|
package/src/client.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
2
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
|
-
import {
|
|
4
|
+
import { createBrowserRouter, RouterProvider } from "react-router";
|
|
5
5
|
|
|
6
6
|
import { DashboardShell } from "./client/App";
|
|
7
7
|
import { getDashboardAgentName } from "./client/agentName";
|
|
@@ -79,12 +79,15 @@ const queryClient = new QueryClient({
|
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
const router = createBrowserRouter(
|
|
83
|
+
[{ path: "*", element: <DashboardShell /> }],
|
|
84
|
+
{ basename: window.__JUNIOR_DASHBOARD_BASE_PATH__ ?? "/" },
|
|
85
|
+
);
|
|
86
|
+
|
|
82
87
|
createRoot(root).render(
|
|
83
88
|
<DashboardErrorBoundary>
|
|
84
89
|
<QueryClientProvider client={queryClient}>
|
|
85
|
-
<
|
|
86
|
-
<DashboardShell />
|
|
87
|
-
</BrowserRouter>
|
|
90
|
+
<RouterProvider router={router} />
|
|
88
91
|
</QueryClientProvider>
|
|
89
92
|
</DashboardErrorBoundary>,
|
|
90
93
|
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
automationListQuerySchema,
|
|
3
|
+
type AutomationList,
|
|
4
|
+
type AutomationSummary,
|
|
5
|
+
} from "@sentry/junior/api/schema";
|
|
6
|
+
|
|
7
|
+
function creator(automation: AutomationSummary) {
|
|
8
|
+
return automation.createdByEmail ?? automation.createdBy;
|
|
9
|
+
}
|
|
10
|
+
function destination(automation: AutomationSummary) {
|
|
11
|
+
return `${automation.destination.teamId}:${automation.destination.channelId}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Apply web collection controls to local preview and browser fixtures. */
|
|
15
|
+
export function mockAutomationCollection(
|
|
16
|
+
report: Pick<
|
|
17
|
+
AutomationList,
|
|
18
|
+
"automations" | "executionDays" | "executionHours" | "executionSixHours"
|
|
19
|
+
>,
|
|
20
|
+
params: URLSearchParams = new URLSearchParams(),
|
|
21
|
+
): AutomationList {
|
|
22
|
+
const input = automationListQuerySchema.parse(Object.fromEntries(params));
|
|
23
|
+
const all = report.automations;
|
|
24
|
+
const filtered = all
|
|
25
|
+
.filter((automation) => {
|
|
26
|
+
const search =
|
|
27
|
+
`${automation.title} ${automation.instruction} ${automation.kind === "event" ? automation.resource : ""}`.toLowerCase();
|
|
28
|
+
return (
|
|
29
|
+
(input.scope !== "mine" || automation.ownedByViewer) &&
|
|
30
|
+
(input.scope !== "public" ||
|
|
31
|
+
automation.destination.visibility === "public") &&
|
|
32
|
+
(input.scope !== "attention" ||
|
|
33
|
+
(automation.status !== "paused" &&
|
|
34
|
+
automation.status !== "completed" &&
|
|
35
|
+
(automation.status === "blocked" ||
|
|
36
|
+
(automation.kind === "event" && !automation.triggerAvailable) ||
|
|
37
|
+
automation.lastRunStatus === "failed" ||
|
|
38
|
+
automation.lastRunStatus === "blocked"))) &&
|
|
39
|
+
(input.type === "all" || automation.kind === input.type) &&
|
|
40
|
+
(input.state === "all" ||
|
|
41
|
+
(input.state === "unavailable"
|
|
42
|
+
? automation.kind === "event" && !automation.triggerAvailable
|
|
43
|
+
: automation.status === input.state)) &&
|
|
44
|
+
(!input.creator || creator(automation) === input.creator) &&
|
|
45
|
+
(!input.destination || destination(automation) === input.destination) &&
|
|
46
|
+
(!input.q || search.includes(input.q.toLowerCase()))
|
|
47
|
+
);
|
|
48
|
+
})
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
const order =
|
|
51
|
+
input.sort === "title"
|
|
52
|
+
? a.title.localeCompare(b.title)
|
|
53
|
+
: input.sort === "oldest"
|
|
54
|
+
? a.createdAt.localeCompare(b.createdAt)
|
|
55
|
+
: b.createdAt.localeCompare(a.createdAt);
|
|
56
|
+
return order || b.id.localeCompare(a.id);
|
|
57
|
+
});
|
|
58
|
+
const page = Math.min(
|
|
59
|
+
input.page,
|
|
60
|
+
Math.max(1, Math.ceil(filtered.length / input.pageSize)),
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
...report,
|
|
64
|
+
automations: filtered.slice(
|
|
65
|
+
(page - 1) * input.pageSize,
|
|
66
|
+
page * input.pageSize,
|
|
67
|
+
),
|
|
68
|
+
total: filtered.length,
|
|
69
|
+
page,
|
|
70
|
+
pageSize: input.pageSize,
|
|
71
|
+
counts: {
|
|
72
|
+
all: all.length,
|
|
73
|
+
mine: all.filter((automation) => automation.ownedByViewer).length,
|
|
74
|
+
public: all.filter(
|
|
75
|
+
(automation) => automation.destination.visibility === "public",
|
|
76
|
+
).length,
|
|
77
|
+
private: all.filter(
|
|
78
|
+
(automation) => automation.destination.visibility === "private",
|
|
79
|
+
).length,
|
|
80
|
+
},
|
|
81
|
+
creators: [
|
|
82
|
+
...new Map(
|
|
83
|
+
all.map((automation) => [
|
|
84
|
+
creator(automation),
|
|
85
|
+
{ value: creator(automation), label: automation.createdBy },
|
|
86
|
+
]),
|
|
87
|
+
).values(),
|
|
88
|
+
],
|
|
89
|
+
destinations: [
|
|
90
|
+
...new Map(
|
|
91
|
+
all.map((automation) => [
|
|
92
|
+
destination(automation),
|
|
93
|
+
{
|
|
94
|
+
value: destination(automation),
|
|
95
|
+
label: automation.destination.label,
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
).values(),
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mockAutomationCollection } from "./automation-collection";
|
|
1
2
|
/** Deterministic reporting fixtures for local dashboard development and QA. */
|
|
2
3
|
import type {
|
|
3
4
|
ActorDirectoryReport,
|
|
@@ -162,8 +163,9 @@ function reportEvent(
|
|
|
162
163
|
seq: number,
|
|
163
164
|
createdAt: string,
|
|
164
165
|
data: ConversationReportEventData,
|
|
166
|
+
metadata?: Pick<ConversationReportEvent, "model" | "modelCall">,
|
|
165
167
|
): ConversationReportEvent {
|
|
166
|
-
return { seq, createdAt, data };
|
|
168
|
+
return { seq, createdAt, data, ...metadata };
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
type DetailOptions = Omit<
|
|
@@ -453,38 +455,81 @@ function activeConversation(nowMs: number): ConversationDetailReport {
|
|
|
453
455
|
reasoningLevel: "high",
|
|
454
456
|
confidence: 0.93,
|
|
455
457
|
source: "router",
|
|
458
|
+
costUsd: 0.0012,
|
|
456
459
|
}),
|
|
457
|
-
reportEvent(
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
460
|
+
reportEvent(
|
|
461
|
+
4,
|
|
462
|
+
iso(Date.parse(startedAt), 10_000),
|
|
463
|
+
{
|
|
464
|
+
type: "tool_calls",
|
|
465
|
+
calls: [
|
|
466
|
+
{
|
|
467
|
+
toolCallId: "active-search",
|
|
468
|
+
name: "webSearch",
|
|
469
|
+
status: "running",
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
model: {
|
|
475
|
+
modelId: "openai/gpt-5.6-sol",
|
|
476
|
+
modelProfile: "handoff",
|
|
477
|
+
reasoningLevel: "high",
|
|
464
478
|
},
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
],
|
|
479
|
+
modelCall: {
|
|
480
|
+
provider: "vercel-ai-gateway",
|
|
481
|
+
api: "openai-responses",
|
|
482
|
+
stopReason: "toolUse",
|
|
483
|
+
usage: {
|
|
484
|
+
inputTokens: 12500,
|
|
485
|
+
outputTokens: 800,
|
|
486
|
+
cachedInputTokens: 32000,
|
|
487
|
+
cacheCreationTokens: 2000,
|
|
488
|
+
reasoningTokens: 600,
|
|
489
|
+
totalTokens: 47300,
|
|
490
|
+
cost: {
|
|
491
|
+
input: 0.025,
|
|
492
|
+
output: 0.008,
|
|
493
|
+
cacheRead: 0.0064,
|
|
494
|
+
cacheWrite: 0.005,
|
|
495
|
+
total: 0.0444,
|
|
496
|
+
},
|
|
484
497
|
},
|
|
485
498
|
},
|
|
486
|
-
|
|
487
|
-
|
|
499
|
+
},
|
|
500
|
+
),
|
|
501
|
+
reportEvent(
|
|
502
|
+
5,
|
|
503
|
+
iso(Date.parse(startedAt), 14_000),
|
|
504
|
+
{
|
|
505
|
+
type: "tool_calls",
|
|
506
|
+
calls: [
|
|
507
|
+
{
|
|
508
|
+
toolCallId: "active-search",
|
|
509
|
+
name: "webSearch",
|
|
510
|
+
status: "completed",
|
|
511
|
+
startedSeq: 4,
|
|
512
|
+
startedAt: iso(Date.parse(startedAt), 10_000),
|
|
513
|
+
input: { query: "checkout latency last deployment" },
|
|
514
|
+
output: {
|
|
515
|
+
results: [
|
|
516
|
+
{
|
|
517
|
+
title: "payments-v42 deploy notes",
|
|
518
|
+
url: "https://docs.sentry.io",
|
|
519
|
+
},
|
|
520
|
+
],
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
model: {
|
|
527
|
+
modelId: "openai/gpt-5.6-sol",
|
|
528
|
+
modelProfile: "handoff",
|
|
529
|
+
reasoningLevel: "high",
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
),
|
|
488
533
|
// Mixed markdown keeps font/legibility QA honest for long assistant replies.
|
|
489
534
|
reportEvent(6, iso(Date.parse(startedAt), 22_000), {
|
|
490
535
|
type: "message",
|
|
@@ -2625,8 +2670,10 @@ function mockTasks(): AutomationSummary[] {
|
|
|
2625
2670
|
lastConversationId: "scheduler:daily-ops-digest",
|
|
2626
2671
|
lastRunAt: "2026-08-06T16:00:00.000Z",
|
|
2627
2672
|
nextRunAt: "2026-08-10T16:00:00.000Z",
|
|
2673
|
+
credentialMode: "creator",
|
|
2628
2674
|
ownedByViewer: true,
|
|
2629
2675
|
runs: { 1: 1, 7: 3, 30: 12, 90: 48 },
|
|
2676
|
+
timezone: "America/Los_Angeles",
|
|
2630
2677
|
schedule: "Every Monday at 9:00 AM",
|
|
2631
2678
|
status: "active",
|
|
2632
2679
|
outcomes: [
|
|
@@ -2654,6 +2701,7 @@ function mockTasks(): AutomationSummary[] {
|
|
|
2654
2701
|
kind: "event",
|
|
2655
2702
|
lastConversationId: "agent-dispatch:event-1",
|
|
2656
2703
|
lastRunAt: "2026-08-05T18:30:00.000Z",
|
|
2704
|
+
credentialMode: "creator",
|
|
2657
2705
|
ownedByViewer: true,
|
|
2658
2706
|
resource: "Issue · ACME-42",
|
|
2659
2707
|
runs: { 1: 0, 7: 1, 30: 4, 90: 7 },
|
|
@@ -2662,6 +2710,7 @@ function mockTasks(): AutomationSummary[] {
|
|
|
2662
2710
|
title: "Closed issue summary",
|
|
2663
2711
|
totalRuns: 7,
|
|
2664
2712
|
triggerAvailable: true,
|
|
2713
|
+
status: "active",
|
|
2665
2714
|
},
|
|
2666
2715
|
{
|
|
2667
2716
|
createdAt: "2026-07-30T16:00:00.000Z",
|
|
@@ -2677,6 +2726,7 @@ function mockTasks(): AutomationSummary[] {
|
|
|
2677
2726
|
id: "event-2",
|
|
2678
2727
|
instruction: "Notify responders when the incident changes",
|
|
2679
2728
|
kind: "event",
|
|
2729
|
+
credentialMode: "creator",
|
|
2680
2730
|
ownedByViewer: false,
|
|
2681
2731
|
resource: "Incident · INC-17",
|
|
2682
2732
|
runs: { 1: 0, 7: 0, 30: 0, 90: 0 },
|
|
@@ -2690,13 +2740,14 @@ function mockTasks(): AutomationSummary[] {
|
|
|
2690
2740
|
title: "Incident change alerts",
|
|
2691
2741
|
totalRuns: 0,
|
|
2692
2742
|
triggerAvailable: false,
|
|
2743
|
+
status: "active",
|
|
2693
2744
|
},
|
|
2694
2745
|
];
|
|
2695
2746
|
}
|
|
2696
2747
|
|
|
2697
2748
|
/** Build mock Tasks list for local dashboard development. */
|
|
2698
2749
|
export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
|
|
2699
|
-
return {
|
|
2750
|
+
return mockAutomationCollection({
|
|
2700
2751
|
executionDays: mockAutomationExecutionDays(nowMs),
|
|
2701
2752
|
executionHours: trailingMetricHours(nowMs, (date) => ({
|
|
2702
2753
|
costUsd: 0,
|
|
@@ -2715,8 +2766,7 @@ export function readMockAutomationList(nowMs = NOW_MS): AutomationList {
|
|
|
2715
2766
|
(date) => ({ costUsd: 0, date, event: 0, scheduled: 0 }),
|
|
2716
2767
|
),
|
|
2717
2768
|
automations: mockTasks(),
|
|
2718
|
-
|
|
2719
|
-
};
|
|
2769
|
+
});
|
|
2720
2770
|
}
|
|
2721
2771
|
|
|
2722
2772
|
function mockStatusDays(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mockAutomationCollection } from "./automation-collection";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { jsonResponse, type JuniorApiVariables } from "@sentry/junior/api";
|
|
3
4
|
import {
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
automationExecutionListSchema,
|
|
27
28
|
automationListSchema,
|
|
28
29
|
automationParamsSchema,
|
|
30
|
+
automationSummarySchema,
|
|
29
31
|
automationRunListSchema,
|
|
30
32
|
} from "@sentry/junior/api/schema";
|
|
31
33
|
import { mockChartPng } from "./chart-png";
|
|
@@ -253,15 +255,10 @@ export function createMockReportingApi(): Hono<{
|
|
|
253
255
|
});
|
|
254
256
|
app.get("/automations", (c) => {
|
|
255
257
|
const report = readMockAutomationList();
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
? report.automations.filter((automation) =>
|
|
261
|
-
automation.title.toLowerCase().includes(query),
|
|
262
|
-
)
|
|
263
|
-
: report.automations,
|
|
264
|
-
});
|
|
258
|
+
return jsonResponse(
|
|
259
|
+
automationListSchema,
|
|
260
|
+
mockAutomationCollection(report, new URL(c.req.url).searchParams),
|
|
261
|
+
);
|
|
265
262
|
});
|
|
266
263
|
app.get("/automations/runs", () => {
|
|
267
264
|
const automations = readMockAutomationList().automations;
|
|
@@ -284,6 +281,14 @@ export function createMockReportingApi(): Hono<{
|
|
|
284
281
|
truncated: false,
|
|
285
282
|
});
|
|
286
283
|
});
|
|
284
|
+
app.get("/automations/:id", (c) => {
|
|
285
|
+
const automation = readMockAutomationList().automations.find(
|
|
286
|
+
(automation) => automation.id === c.req.param("id"),
|
|
287
|
+
);
|
|
288
|
+
return automation
|
|
289
|
+
? jsonResponse(automationSummarySchema, automation)
|
|
290
|
+
: errorResponse("Automation not found.", 404);
|
|
291
|
+
});
|
|
287
292
|
app.get("/automations/:kind/:id/executions", (c) => {
|
|
288
293
|
const params = automationParamsSchema.safeParse(c.req.param());
|
|
289
294
|
if (!params.success) {
|