@sentry/junior-dashboard 0.256.0 → 0.258.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/README.md +3 -2
- package/dist/app.js +65 -184
- package/dist/client.js +37 -37
- package/dist/index.d.ts +36 -42
- package/dist/index.js +65 -184
- package/dist/tailwind.css +1 -1
- package/package.json +6 -6
- package/src/api/schema.ts +1 -2
- package/src/app.ts +15 -73
- package/src/auth.ts +8 -131
- package/src/authenticated-routes.ts +1 -2
- package/src/client/components/SearchInput.tsx +26 -4
- package/src/client/conversations/ConversationTranscript.tsx +11 -8
- package/src/client/conversations/TranscriptActivityGroup.tsx +3 -2
- package/src/client/conversations/TranscriptEventView.tsx +2 -1
- package/src/client/conversations/TranscriptMarkdown.tsx +31 -14
- package/src/client/conversations/eventTranscript.ts +1 -0
- package/src/client/conversations/transcriptFailure.ts +0 -4
- package/src/client/conversations/transcriptMarkdownLinks.ts +3 -0
- package/src/client/conversations/transcriptMentions.tsx +104 -0
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +4 -4
- package/src/client/pages/automations/automationDraft.ts +1 -1
- package/src/client/pages/dev/ComponentsPage.tsx +8 -0
- package/src/client/pages/people/ProfileCodeActivity.tsx +9 -1
- package/src/client/pages/people/ProfileCodeChanges.tsx +104 -0
- package/src/client/pages/system/PluginBarChart.tsx +8 -8
- package/src/client/types.ts +2 -0
- package/src/client.tsx +3 -3
- package/src/mock-reporting/fixtures.ts +15 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
4
|
+
|
|
5
|
+
import { peoplePath } from "../format";
|
|
6
|
+
import type { TranscriptViewMessage } from "../types";
|
|
7
|
+
import { TRANSCRIPT_ANCHOR_CLASS } from "./transcriptMarkdownLinks";
|
|
8
|
+
import { HighlightText } from "./transcriptSearch";
|
|
9
|
+
|
|
10
|
+
type TranscriptMention = {
|
|
11
|
+
end: number;
|
|
12
|
+
id: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
sigil: "@" | "#";
|
|
15
|
+
start: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Matches the user and channel tokens that core keeps in message text.
|
|
19
|
+
const SLACK_MENTION_RE = /<([@#])([A-Z0-9]+)(?:\|([^<>]*))?>/g;
|
|
20
|
+
|
|
21
|
+
const TranscriptMentionPeople = createContext<
|
|
22
|
+
ReadonlyMap<string, ActorIdentity>
|
|
23
|
+
>(new Map());
|
|
24
|
+
|
|
25
|
+
/** Share the people that the API resolved for mentions in one transcript. */
|
|
26
|
+
export function TranscriptMentionPeopleProvider(props: {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
messages: readonly TranscriptViewMessage[];
|
|
29
|
+
}) {
|
|
30
|
+
const people = new Map<string, ActorIdentity>();
|
|
31
|
+
for (const message of props.messages) {
|
|
32
|
+
for (const person of message.mentions ?? []) {
|
|
33
|
+
if (person.slackUserId) people.set(person.slackUserId, person);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return (
|
|
37
|
+
<TranscriptMentionPeople.Provider value={people}>
|
|
38
|
+
{props.children}
|
|
39
|
+
</TranscriptMentionPeople.Provider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render text with Slack `<@U123>` and `<#C123|name>` tokens as readable mentions. */
|
|
44
|
+
export function renderMentionText(text: string, keyBase: string): ReactNode[] {
|
|
45
|
+
const nodes: ReactNode[] = [];
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
for (const mention of findMentions(text)) {
|
|
48
|
+
if (mention.start > cursor) {
|
|
49
|
+
nodes.push(
|
|
50
|
+
<HighlightText
|
|
51
|
+
key={`${keyBase}-m-${cursor}`}
|
|
52
|
+
text={text.slice(cursor, mention.start)}
|
|
53
|
+
/>,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
nodes.push(
|
|
57
|
+
<MentionView key={`${keyBase}-m-${mention.start}`} mention={mention} />,
|
|
58
|
+
);
|
|
59
|
+
cursor = mention.end;
|
|
60
|
+
}
|
|
61
|
+
if (cursor < text.length) {
|
|
62
|
+
nodes.push(
|
|
63
|
+
<HighlightText
|
|
64
|
+
key={`${keyBase}-m-${cursor}`}
|
|
65
|
+
text={text.slice(cursor)}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return nodes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findMentions(text: string): TranscriptMention[] {
|
|
73
|
+
return [...text.matchAll(SLACK_MENTION_RE)].map((match) => ({
|
|
74
|
+
end: match.index + match[0].length,
|
|
75
|
+
id: match[2]!,
|
|
76
|
+
label: match[3]?.trim() || undefined,
|
|
77
|
+
sigil: match[1] as "@" | "#",
|
|
78
|
+
start: match.index,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function MentionView(props: { mention: TranscriptMention }) {
|
|
83
|
+
const people = useContext(TranscriptMentionPeople);
|
|
84
|
+
const { id, label, sigil } = props.mention;
|
|
85
|
+
const person = sigil === "@" ? people.get(id) : undefined;
|
|
86
|
+
const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
|
|
87
|
+
const text = <HighlightText text={`${sigil}${name.replace(/^[@#]/, "")}`} />;
|
|
88
|
+
if (person?.email) {
|
|
89
|
+
return (
|
|
90
|
+
<Link
|
|
91
|
+
className={TRANSCRIPT_ANCHOR_CLASS}
|
|
92
|
+
title={id}
|
|
93
|
+
to={peoplePath(person.email)}
|
|
94
|
+
>
|
|
95
|
+
{text}
|
|
96
|
+
</Link>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return (
|
|
100
|
+
<span className="font-medium text-dashboard-accent-text" title={id}>
|
|
101
|
+
{text}
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -141,10 +141,10 @@ export function AutomationOutcomeFields(props: {
|
|
|
141
141
|
disabled={index === 0}
|
|
142
142
|
onClick={() => {
|
|
143
143
|
const next = [...props.value];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
];
|
|
144
|
+
const previous = next[index - 1];
|
|
145
|
+
if (!previous) return;
|
|
146
|
+
next[index - 1] = outcome;
|
|
147
|
+
next[index] = previous;
|
|
148
148
|
props.onChange(next);
|
|
149
149
|
}}
|
|
150
150
|
>
|
|
@@ -53,7 +53,7 @@ export function scheduleDraft(
|
|
|
53
53
|
...fields,
|
|
54
54
|
timezone: value.schedule.timezone,
|
|
55
55
|
time: `${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}`,
|
|
56
|
-
weekdays: days?.
|
|
56
|
+
weekdays: days?.flatMap((day) => weekdays[day] ?? []),
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
if (value.nextRunAtMs) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FormNotice } from "../../components/FormNotice";
|
|
2
|
+
import { SearchInput } from "../../components/SearchInput";
|
|
2
3
|
import { Select } from "../../components/Select";
|
|
3
4
|
import { ObjectFixtures } from "./ObjectFixtures";
|
|
4
5
|
import { ChartFixtures } from "./ChartFixtures";
|
|
@@ -191,6 +192,7 @@ function FoundationsGalleryPage() {
|
|
|
191
192
|
const [navigationOpen, setNavigationOpen] = useState(false);
|
|
192
193
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
193
194
|
const [nestedDrawerOpen, setNestedDrawerOpen] = useState(false);
|
|
195
|
+
const [searchQuery, setSearchQuery] = useState("deploy failure");
|
|
194
196
|
|
|
195
197
|
return (
|
|
196
198
|
<GalleryShell
|
|
@@ -471,6 +473,12 @@ function FoundationsGalleryPage() {
|
|
|
471
473
|
disabled
|
|
472
474
|
value="read only"
|
|
473
475
|
/>
|
|
476
|
+
<SearchInput
|
|
477
|
+
label="Search conversations"
|
|
478
|
+
onChange={setSearchQuery}
|
|
479
|
+
placeholder="Search conversations…"
|
|
480
|
+
value={searchQuery}
|
|
481
|
+
/>
|
|
474
482
|
</div>
|
|
475
483
|
</Fixture>
|
|
476
484
|
<Fixture title="Status labels">
|
|
@@ -10,6 +10,7 @@ import { SectionIntro } from "../../components/layout/SectionIntro";
|
|
|
10
10
|
import { StatCard } from "../../components/metrics/StatCard";
|
|
11
11
|
import { formatCompactNumber, formatCostSummary } from "../../format";
|
|
12
12
|
import { CodeActivityChart } from "../code/CodeActivityChart";
|
|
13
|
+
import { ProfileCodeChanges } from "./ProfileCodeChanges";
|
|
13
14
|
|
|
14
15
|
function mergeRate(value: number | undefined): string {
|
|
15
16
|
return value === undefined ? "—" : `${Math.round(value * 100)}%`;
|
|
@@ -28,13 +29,14 @@ function costUsd(value: number | undefined): string {
|
|
|
28
29
|
function hasCodeActivity(report: CodePersonReport): boolean {
|
|
29
30
|
const summary = report.summary;
|
|
30
31
|
return (
|
|
32
|
+
report.changes.length > 0 ||
|
|
31
33
|
summary.created + summary.merged + summary.closed > 0 ||
|
|
32
34
|
(summary.costUsd ?? 0) > 0 ||
|
|
33
35
|
report.activityDays.some((day) => day.created + day.merged + day.closed > 0)
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/** Render person-scoped native code change activity. */
|
|
39
|
+
/** Render person-scoped native code change activity and recent code changes. */
|
|
38
40
|
export function ProfileCodeActivity(props: {
|
|
39
41
|
range: TimeRangeDays;
|
|
40
42
|
report: CodePersonReport;
|
|
@@ -86,6 +88,12 @@ export function ProfileCodeActivity(props: {
|
|
|
86
88
|
})}
|
|
87
89
|
range={props.range}
|
|
88
90
|
/>
|
|
91
|
+
{props.report.changes.length > 0 ? (
|
|
92
|
+
<ProfileCodeChanges
|
|
93
|
+
changes={props.report.changes}
|
|
94
|
+
generatedAt={props.report.generatedAt}
|
|
95
|
+
/>
|
|
96
|
+
) : null}
|
|
89
97
|
</section>
|
|
90
98
|
);
|
|
91
99
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { CodeChangeSummaryReport } from "@sentry/junior/api/schema";
|
|
3
|
+
|
|
4
|
+
import { EmptyTelemetry } from "../../components/EmptyTelemetry";
|
|
5
|
+
import { FilterGroup } from "../../components/FilterBar";
|
|
6
|
+
import { Select } from "../../components/Select";
|
|
7
|
+
import { Card } from "../../components/layout/Card";
|
|
8
|
+
import { CodeChangeRow } from "../code/CodeChangeRow";
|
|
9
|
+
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
11
|
+
|
|
12
|
+
const STATE_OPTIONS = [
|
|
13
|
+
{ label: "Open", value: "open" },
|
|
14
|
+
{ label: "Merged", value: "merged" },
|
|
15
|
+
{ label: "Closed", value: "closed" },
|
|
16
|
+
{ label: "All", value: "" },
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
const OPENED_OPTIONS = [
|
|
20
|
+
{ label: "Any time", value: "" },
|
|
21
|
+
{ label: "Last 24 hours", value: "1" },
|
|
22
|
+
{ label: "Last 7 days", value: "7" },
|
|
23
|
+
{ label: "Last 30 days", value: "30" },
|
|
24
|
+
{ label: "Last 90 days", value: "90" },
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* List one person's recent code changes. Open changes show first, and the
|
|
29
|
+
* person can filter by state and by the date the change was opened.
|
|
30
|
+
*/
|
|
31
|
+
export function ProfileCodeChanges(props: {
|
|
32
|
+
changes: CodeChangeSummaryReport[];
|
|
33
|
+
generatedAt: string;
|
|
34
|
+
}) {
|
|
35
|
+
const [state, setState] = useState("open");
|
|
36
|
+
const [openedDays, setOpenedDays] = useState("");
|
|
37
|
+
const openedSinceMs = openedDays
|
|
38
|
+
? Date.parse(props.generatedAt) - Number(openedDays) * DAY_MS
|
|
39
|
+
: undefined;
|
|
40
|
+
const inDateRange = props.changes.filter(
|
|
41
|
+
(change) =>
|
|
42
|
+
openedSinceMs === undefined ||
|
|
43
|
+
Date.parse(change.openedAt) >= openedSinceMs,
|
|
44
|
+
);
|
|
45
|
+
const changes = inDateRange.filter(
|
|
46
|
+
(change) => !state || change.state === state,
|
|
47
|
+
);
|
|
48
|
+
const count = (value: string) =>
|
|
49
|
+
value
|
|
50
|
+
? inDateRange.filter((change) => change.state === value).length
|
|
51
|
+
: inDateRange.length;
|
|
52
|
+
return (
|
|
53
|
+
<Card as="section">
|
|
54
|
+
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3">
|
|
55
|
+
<div className="font-display text-lg text-dashboard-text">
|
|
56
|
+
Recent changes
|
|
57
|
+
</div>
|
|
58
|
+
<div className="flex flex-wrap gap-3">
|
|
59
|
+
<FilterGroup className="w-36" label="State">
|
|
60
|
+
<Select
|
|
61
|
+
aria-label="Filter changes by state"
|
|
62
|
+
onChange={(event) => setState(event.currentTarget.value)}
|
|
63
|
+
value={state}
|
|
64
|
+
>
|
|
65
|
+
{STATE_OPTIONS.map((option) => (
|
|
66
|
+
<option key={option.value} value={option.value}>
|
|
67
|
+
{option.label} ({count(option.value)})
|
|
68
|
+
</option>
|
|
69
|
+
))}
|
|
70
|
+
</Select>
|
|
71
|
+
</FilterGroup>
|
|
72
|
+
<FilterGroup className="w-36" label="Opened">
|
|
73
|
+
<Select
|
|
74
|
+
aria-label="Filter changes by opened date"
|
|
75
|
+
onChange={(event) => setOpenedDays(event.currentTarget.value)}
|
|
76
|
+
value={openedDays}
|
|
77
|
+
>
|
|
78
|
+
{OPENED_OPTIONS.map((option) => (
|
|
79
|
+
<option key={option.value} value={option.value}>
|
|
80
|
+
{option.label}
|
|
81
|
+
</option>
|
|
82
|
+
))}
|
|
83
|
+
</Select>
|
|
84
|
+
</FilterGroup>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
{changes.length > 0 ? (
|
|
88
|
+
<div>
|
|
89
|
+
{changes.map((change) => (
|
|
90
|
+
<CodeChangeRow change={change} key={change.id} />
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<div className="p-4">
|
|
95
|
+
<EmptyTelemetry>
|
|
96
|
+
{state && inDateRange.length > 0
|
|
97
|
+
? `No ${state} changes. Set State to All to see ${inDateRange.length} more.`
|
|
98
|
+
: "No changes match these filters."}
|
|
99
|
+
</EmptyTelemetry>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</Card>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -23,6 +23,12 @@ const toneColors = {
|
|
|
23
23
|
warning: "#fbbf24",
|
|
24
24
|
} as const;
|
|
25
25
|
|
|
26
|
+
function seriesColor(series: Widget["series"][number], index: number): string {
|
|
27
|
+
return series.tone
|
|
28
|
+
? toneColors[series.tone]
|
|
29
|
+
: (colors[index % colors.length] ?? colors[0]);
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
/** Render a validated plugin-owned categorical bar chart. */
|
|
27
33
|
export function PluginBarChart(props: {
|
|
28
34
|
range?: TimeRangeDays;
|
|
@@ -69,9 +75,7 @@ export function PluginBarChart(props: {
|
|
|
69
75
|
<ChartLegend
|
|
70
76
|
ariaLabel="Chart legend"
|
|
71
77
|
items={widget.series.map((series, index) => ({
|
|
72
|
-
color: series
|
|
73
|
-
? toneColors[series.tone]
|
|
74
|
-
: colors[index % colors.length],
|
|
78
|
+
color: seriesColor(series, index),
|
|
75
79
|
key: series.key,
|
|
76
80
|
label: series.label,
|
|
77
81
|
}))}
|
|
@@ -122,11 +126,7 @@ export function PluginBarChart(props: {
|
|
|
122
126
|
>
|
|
123
127
|
<rect
|
|
124
128
|
aria-label={`${formatActivityCategoryTooltipLabel(category.label)}, ${series.label}: ${formatted}`}
|
|
125
|
-
fill={
|
|
126
|
-
series.tone
|
|
127
|
-
? toneColors[series.tone]
|
|
128
|
-
: colors[seriesIndex % colors.length]
|
|
129
|
-
}
|
|
129
|
+
fill={seriesColor(series, seriesIndex)}
|
|
130
130
|
height={renderedHeight}
|
|
131
131
|
opacity={value ? 0.82 : 0.1}
|
|
132
132
|
rx="1.5"
|
package/src/client/types.ts
CHANGED
|
@@ -122,6 +122,8 @@ export type TranscriptViewMessage = {
|
|
|
122
122
|
trustedSummary?: string;
|
|
123
123
|
/** Whether the source message addressed Junior directly. */
|
|
124
124
|
explicitMention?: boolean;
|
|
125
|
+
/** People mentioned in the message text. */
|
|
126
|
+
mentions?: ActorIdentity[];
|
|
125
127
|
/** Whether a non-mention message was used as input to a turn. */
|
|
126
128
|
context?: boolean;
|
|
127
129
|
/** Stable history/message id used to drop pending rows after commit. */
|
package/src/client.tsx
CHANGED
|
@@ -22,18 +22,18 @@ class DashboardErrorBoundary extends Component<
|
|
|
22
22
|
{ children: ReactNode },
|
|
23
23
|
ErrorBoundaryState
|
|
24
24
|
> {
|
|
25
|
-
state: ErrorBoundaryState = { error: null };
|
|
25
|
+
override state: ErrorBoundaryState = { error: null };
|
|
26
26
|
|
|
27
27
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
28
28
|
return { error };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
31
|
+
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
32
32
|
const stack = error.stack ?? errorInfo.componentStack;
|
|
33
33
|
window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
render() {
|
|
36
|
+
override render() {
|
|
37
37
|
if (this.state.error) {
|
|
38
38
|
return <DashboardErrorPanel error={this.state.error} />;
|
|
39
39
|
}
|
|
@@ -1397,6 +1397,13 @@ function simpleConversation(
|
|
|
1397
1397
|
});
|
|
1398
1398
|
}
|
|
1399
1399
|
|
|
1400
|
+
const MOCK_MENTION_MORGAN: ActorIdentity = {
|
|
1401
|
+
email: "dev@example.com",
|
|
1402
|
+
fullName: "Morgan Lee",
|
|
1403
|
+
slackUserId: "U123",
|
|
1404
|
+
slackUserName: "morgan",
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1400
1407
|
function eventAutomationRunConversation(
|
|
1401
1408
|
nowMs: number,
|
|
1402
1409
|
): ConversationDetailReport {
|
|
@@ -1444,6 +1451,7 @@ function eventAutomationRunConversation(
|
|
|
1444
1451
|
"",
|
|
1445
1452
|
"End with `finishAutomationRun`.",
|
|
1446
1453
|
].join("\n"),
|
|
1454
|
+
mentions: [MOCK_MENTION_MORGAN],
|
|
1447
1455
|
}),
|
|
1448
1456
|
reportEvent(1, at(1_000), {
|
|
1449
1457
|
type: "turn_lifecycle",
|
|
@@ -1455,7 +1463,12 @@ function eventAutomationRunConversation(
|
|
|
1455
1463
|
type: "message",
|
|
1456
1464
|
messageId: `${EVENT_AUTOMATION_RUN_CONVERSATION_ID}:reply`,
|
|
1457
1465
|
role: "assistant",
|
|
1458
|
-
text: "ACME-42 is closed. The saved-card checkout failure was fixed by the token refresh change and verified in production.",
|
|
1466
|
+
text: "<@U123> ACME-42 is closed. The saved-card checkout failure was fixed by the token refresh change and verified in production. cc <@U456> and <@U999> in <#C42|proj-checkout>.",
|
|
1467
|
+
// U456 has no People profile, and U999 is not resolved.
|
|
1468
|
+
mentions: [
|
|
1469
|
+
MOCK_MENTION_MORGAN,
|
|
1470
|
+
{ fullName: "Casey Nguyen", slackUserId: "U456" },
|
|
1471
|
+
],
|
|
1459
1472
|
}),
|
|
1460
1473
|
reportEvent(3, at(14_000), {
|
|
1461
1474
|
type: "turn_lifecycle",
|
|
@@ -2682,6 +2695,7 @@ export function readMockPeopleCode(
|
|
|
2682
2695
|
const overview = readMockCodeOverview();
|
|
2683
2696
|
return {
|
|
2684
2697
|
activityDays: overview.activityDays,
|
|
2698
|
+
changes: overview.changes,
|
|
2685
2699
|
generatedAt: overview.generatedAt,
|
|
2686
2700
|
summary: overview.summary,
|
|
2687
2701
|
windowEnd: overview.windowEnd,
|