@sentry/junior-dashboard 0.233.0 → 0.235.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 +178 -29
- package/dist/client.js +53 -50
- package/dist/index.js +178 -29
- package/dist/tailwind.css +1 -1
- package/package.json +3 -3
- package/src/assets/junior-avatar.png +0 -0
- package/src/assets/junior-favicon.png +0 -0
- package/src/assets.ts +1 -0
- package/src/client/App.tsx +372 -345
- package/src/client/api.ts +60 -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 +19 -6
- package/src/client/components/SegmentedNav.tsx +167 -0
- package/src/client/components/SegmentedTabs.tsx +24 -5
- 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/components/layout/SecondaryNavigation.tsx +40 -83
- package/src/client/http.ts +31 -3
- package/src/client/pages/automations/AutomationDetailsDrawer.tsx +78 -68
- package/src/client/pages/automations/AutomationEditPage.tsx +171 -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 +179 -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 +343 -0
- package/src/client/pages/automations/AutomationScheduleFields.tsx +328 -0
- package/src/client/pages/automations/AutomationsPage.tsx +186 -439
- package/src/client/pages/automations/AutomationsPageLayout.tsx +19 -7
- package/src/client/pages/automations/README.md +19 -0
- package/src/client/pages/automations/automationDraft.ts +110 -0
- package/src/client/pages/automations/automationFormat.ts +43 -0
- package/src/client/pages/automations/automationOutcomes.ts +23 -0
- package/src/client/pages/code/CodeChangeRow.tsx +41 -0
- package/src/client/pages/code/CodePage.tsx +18 -93
- package/src/client/pages/code/CodeSummaryCards.tsx +53 -0
- package/src/client/pages/code/RepositoryPage.tsx +227 -0
- package/src/client/pages/code/RepositorySections.tsx +108 -0
- package/src/client/pages/code/RepositoryTabs.tsx +146 -0
- package/src/client/pages/code/codeRepositoryRoutes.ts +18 -0
- package/src/client/pages/dev/ComponentsPage.tsx +55 -26
- package/src/client/pages/dev/SegmentedFixtures.tsx +58 -0
- package/src/client/pages/memory/MemoryPageLayout.tsx +12 -13
- package/src/client/pages/system/SystemNavigation.tsx +6 -2
- package/src/client/pages/system/SystemPageLayout.tsx +4 -6
- 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/code-repository.ts +77 -0
- package/src/mock-reporting/fixtures.ts +9 -3
- package/src/mock-reporting/routes.ts +39 -17
- package/src/shell.ts +21 -5
|
@@ -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,16 +26,24 @@ 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(props: { children: ReactNode }) {
|
|
31
|
+
return (
|
|
32
|
+
<SecondaryNavigation
|
|
33
|
+
ariaLabel="Automations navigation"
|
|
34
|
+
items={taskNavigationItems}
|
|
35
|
+
>
|
|
36
|
+
{props.children}
|
|
37
|
+
</SecondaryNavigation>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/** Place the shared secondary navigation above one Automations page. */
|
|
26
42
|
export function AutomationsPageLayout(props: { children: ReactNode }) {
|
|
27
43
|
return (
|
|
28
|
-
|
|
29
|
-
<SecondaryNavigation
|
|
30
|
-
ariaLabel="Automations navigation"
|
|
31
|
-
items={taskNavigationItems}
|
|
32
|
-
/>
|
|
44
|
+
<AutomationsNavigation>
|
|
33
45
|
<PageLayout>{props.children}</PageLayout>
|
|
34
|
-
|
|
46
|
+
</AutomationsNavigation>
|
|
35
47
|
);
|
|
36
48
|
}
|
|
37
49
|
|
|
@@ -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,43 @@
|
|
|
1
|
+
import type { AutomationSummary } from "@sentry/junior/api/schema";
|
|
2
|
+
import { formatTime } from "../../format";
|
|
3
|
+
|
|
4
|
+
type ScheduledAutomation = Extract<AutomationSummary, { kind: "scheduled" }>;
|
|
5
|
+
type EventAutomation = Extract<AutomationSummary, { kind: "event" }>;
|
|
6
|
+
|
|
7
|
+
/** Show the schedule with its timezone unless the schedule text already names it. */
|
|
8
|
+
export function automationScheduleLabel(
|
|
9
|
+
automation: ScheduledAutomation,
|
|
10
|
+
): string {
|
|
11
|
+
return automation.schedule.includes(automation.timezone)
|
|
12
|
+
? automation.schedule
|
|
13
|
+
: `${automation.schedule} (${automation.timezone})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Name event triggers in readable words. */
|
|
17
|
+
export function automationEventNames(automation: EventAutomation): string {
|
|
18
|
+
return automation.events
|
|
19
|
+
.map((event) => event.replaceAll(/[._]/g, " "))
|
|
20
|
+
.join(", ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Summarize event match conditions as one readable line. */
|
|
24
|
+
export function automationEventConditions(automation: EventAutomation): string {
|
|
25
|
+
return Object.entries(automation.match ?? {})
|
|
26
|
+
.map(
|
|
27
|
+
([field, value]) =>
|
|
28
|
+
`${field}: ${Array.isArray(value) ? value.join(" or ") : String(value)}`,
|
|
29
|
+
)
|
|
30
|
+
.join(" · ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format a run time with date, time, and zone for details and tooltips. */
|
|
34
|
+
export function formatAutomationRunTime(value: string): string {
|
|
35
|
+
return formatTime(value, {
|
|
36
|
+
day: "numeric",
|
|
37
|
+
hour: "numeric",
|
|
38
|
+
minute: "2-digit",
|
|
39
|
+
month: "short",
|
|
40
|
+
timeZoneName: "short",
|
|
41
|
+
year: "numeric",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CodeChangeSummaryReport } from "@sentry/junior/api/schema";
|
|
2
|
+
|
|
3
|
+
import { StatusChip } from "../../components/StatusChip";
|
|
4
|
+
|
|
5
|
+
function stateTone(state: CodeChangeSummaryReport["state"]) {
|
|
6
|
+
if (state === "merged") return "success" as const;
|
|
7
|
+
if (state === "open") return "info" as const;
|
|
8
|
+
return "neutral" as const;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Show one code change with its title, repository reference, and state. */
|
|
12
|
+
export function CodeChangeRow(props: { change: CodeChangeSummaryReport }) {
|
|
13
|
+
const change = props.change;
|
|
14
|
+
const title = change.title ?? `${change.repository} #${change.number}`;
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex min-w-0 items-center justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3 last:border-b-0">
|
|
17
|
+
<div className="min-w-0">
|
|
18
|
+
<div className="truncate font-display text-sm text-dashboard-text">
|
|
19
|
+
{change.url ? (
|
|
20
|
+
<a
|
|
21
|
+
className="text-inherit no-underline hover:text-cyan-100"
|
|
22
|
+
href={change.url}
|
|
23
|
+
rel="noreferrer"
|
|
24
|
+
target="_blank"
|
|
25
|
+
>
|
|
26
|
+
{title}
|
|
27
|
+
</a>
|
|
28
|
+
) : (
|
|
29
|
+
title
|
|
30
|
+
)}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
|
|
33
|
+
{change.repository} #{change.number}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
<StatusChip size="compact" tone={stateTone(change.state)}>
|
|
37
|
+
{change.state}
|
|
38
|
+
</StatusChip>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { CodeOverviewReport } from "@sentry/junior/api/schema";
|
|
3
|
-
import {
|
|
3
|
+
import { Link } from "react-router";
|
|
4
4
|
import { useCodeOverviewData } from "../../api";
|
|
5
|
-
import { formatDuration } from "../../components/Duration";
|
|
6
5
|
import { EmptyTelemetry } from "../../components/EmptyTelemetry";
|
|
7
6
|
import { PageRouteLoading } from "../../components/PageRouteLoading";
|
|
8
|
-
import { StatusChip } from "../../components/StatusChip";
|
|
9
7
|
import {
|
|
10
8
|
selectTimeSeries,
|
|
11
9
|
timeRangeBucketUnit,
|
|
@@ -14,29 +12,14 @@ import {
|
|
|
14
12
|
import { Card } from "../../components/layout/Card";
|
|
15
13
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
16
14
|
import { PageLayout } from "../../components/layout/PageLayout";
|
|
17
|
-
import { StatCard } from "../../components/metrics/StatCard";
|
|
18
|
-
import { formatCompactNumber, formatCostSummary } from "../../format";
|
|
19
15
|
import { CodeActivityChart } from "./CodeActivityChart";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function costUsd(value: number | undefined): string {
|
|
30
|
-
return (
|
|
31
|
-
formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function stateTone(state: "closed" | "merged" | "open") {
|
|
36
|
-
if (state === "merged") return "success" as const;
|
|
37
|
-
if (state === "open") return "info" as const;
|
|
38
|
-
return "neutral" as const;
|
|
39
|
-
}
|
|
16
|
+
import { CodeChangeRow } from "./CodeChangeRow";
|
|
17
|
+
import {
|
|
18
|
+
CodeSummaryCards,
|
|
19
|
+
formatCodeCost,
|
|
20
|
+
formatMergeRate,
|
|
21
|
+
} from "./CodeSummaryCards";
|
|
22
|
+
import { codeRepositoryPath } from "./codeRepositoryRoutes";
|
|
40
23
|
|
|
41
24
|
/** Render code analytics and recent code changes. */
|
|
42
25
|
export function CodePage() {
|
|
@@ -77,32 +60,7 @@ function CodeOverview(props: {
|
|
|
77
60
|
const data = props.data;
|
|
78
61
|
return (
|
|
79
62
|
<>
|
|
80
|
-
<
|
|
81
|
-
<StatCard
|
|
82
|
-
detail="In the last 30 days"
|
|
83
|
-
icon={GitPullRequest}
|
|
84
|
-
label="Created"
|
|
85
|
-
value={formatCompactNumber(data.summary.created)}
|
|
86
|
-
/>
|
|
87
|
-
<StatCard
|
|
88
|
-
detail="Share of completed changes that merged"
|
|
89
|
-
icon={LibraryBig}
|
|
90
|
-
label="Merge rate"
|
|
91
|
-
value={mergeRate(data.summary.mergeRate)}
|
|
92
|
-
/>
|
|
93
|
-
<StatCard
|
|
94
|
-
detail="Median time from open to merge in the last 30 days"
|
|
95
|
-
icon={Timer}
|
|
96
|
-
label="Median merge time"
|
|
97
|
-
value={medianMergeTime(data.summary.medianMergeTimeMs)}
|
|
98
|
-
/>
|
|
99
|
-
<StatCard
|
|
100
|
-
detail="Conversation cost for changes opened in the last 30 days"
|
|
101
|
-
icon={Coins}
|
|
102
|
-
label="Cost"
|
|
103
|
-
value={costUsd(data.summary.costUsd)}
|
|
104
|
-
/>
|
|
105
|
-
</div>
|
|
63
|
+
<CodeSummaryCards summary={data.summary} />
|
|
106
64
|
<CodeActivityChart
|
|
107
65
|
bucketUnit={timeRangeBucketUnit(props.range)}
|
|
108
66
|
days={selectTimeSeries({
|
|
@@ -160,18 +118,12 @@ function CodeOverview(props: {
|
|
|
160
118
|
>
|
|
161
119
|
<td className="min-w-0 px-4 py-3">
|
|
162
120
|
<div className="truncate font-display text-sm text-dashboard-text">
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
>
|
|
170
|
-
{repository.name}
|
|
171
|
-
</a>
|
|
172
|
-
) : (
|
|
173
|
-
repository.name
|
|
174
|
-
)}
|
|
121
|
+
<Link
|
|
122
|
+
className="text-inherit no-underline hover:text-cyan-100"
|
|
123
|
+
to={codeRepositoryPath(repository.id)}
|
|
124
|
+
>
|
|
125
|
+
{repository.name}
|
|
126
|
+
</Link>
|
|
175
127
|
</div>
|
|
176
128
|
<div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
|
|
177
129
|
{repository.provider}
|
|
@@ -181,10 +133,10 @@ function CodeOverview(props: {
|
|
|
181
133
|
{repository.created}
|
|
182
134
|
</td>
|
|
183
135
|
<td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
|
|
184
|
-
{
|
|
136
|
+
{formatMergeRate(repository.mergeRate)}
|
|
185
137
|
</td>
|
|
186
138
|
<td className="px-4 py-3 text-right font-mono text-sm whitespace-nowrap text-dashboard-text">
|
|
187
|
-
{
|
|
139
|
+
{formatCodeCost(repository.medianCostUsd)}
|
|
188
140
|
</td>
|
|
189
141
|
</tr>
|
|
190
142
|
))}
|
|
@@ -200,34 +152,7 @@ function CodeOverview(props: {
|
|
|
200
152
|
</div>
|
|
201
153
|
<div>
|
|
202
154
|
{data.changes.map((change) => (
|
|
203
|
-
<
|
|
204
|
-
className="flex min-w-0 items-center justify-between gap-4 border-b border-dashboard-border-subtle px-4 py-3 last:border-b-0"
|
|
205
|
-
key={change.id}
|
|
206
|
-
>
|
|
207
|
-
<div className="min-w-0">
|
|
208
|
-
<div className="truncate font-display text-sm text-dashboard-text">
|
|
209
|
-
{change.url ? (
|
|
210
|
-
<a
|
|
211
|
-
className="text-inherit no-underline hover:text-cyan-100"
|
|
212
|
-
href={change.url}
|
|
213
|
-
rel="noreferrer"
|
|
214
|
-
target="_blank"
|
|
215
|
-
>
|
|
216
|
-
{change.title ??
|
|
217
|
-
`${change.repository} #${change.number}`}
|
|
218
|
-
</a>
|
|
219
|
-
) : (
|
|
220
|
-
(change.title ?? `${change.repository} #${change.number}`)
|
|
221
|
-
)}
|
|
222
|
-
</div>
|
|
223
|
-
<div className="mt-1 truncate font-mono text-xs text-dashboard-text-muted">
|
|
224
|
-
{change.repository} #{change.number} · {change.provider}
|
|
225
|
-
</div>
|
|
226
|
-
</div>
|
|
227
|
-
<StatusChip size="compact" tone={stateTone(change.state)}>
|
|
228
|
-
{change.state}
|
|
229
|
-
</StatusChip>
|
|
230
|
-
</div>
|
|
155
|
+
<CodeChangeRow change={change} key={change.id} />
|
|
231
156
|
))}
|
|
232
157
|
</div>
|
|
233
158
|
</Card>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CodeOverviewReport } from "@sentry/junior/api/schema";
|
|
2
|
+
import { Coins, GitPullRequest, LibraryBig, Timer } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { formatDuration } from "../../components/Duration";
|
|
5
|
+
import { StatCard } from "../../components/metrics/StatCard";
|
|
6
|
+
import { formatCompactNumber, formatCostSummary } from "../../format";
|
|
7
|
+
|
|
8
|
+
/** Format a merge rate, or a dash when no change has finished. */
|
|
9
|
+
export function formatMergeRate(value: number | undefined): string {
|
|
10
|
+
return value === undefined ? "—" : `${Math.round(value * 100)}%`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Format a code cost, or a dash when no cost is recorded. */
|
|
14
|
+
export function formatCodeCost(value: number | undefined): string {
|
|
15
|
+
return (
|
|
16
|
+
formatCostSummary(value === undefined ? undefined : { total: value }) || "—"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Show the 30-day code change summary cards. */
|
|
21
|
+
export function CodeSummaryCards(props: {
|
|
22
|
+
summary: CodeOverviewReport["summary"];
|
|
23
|
+
}) {
|
|
24
|
+
const summary = props.summary;
|
|
25
|
+
return (
|
|
26
|
+
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
27
|
+
<StatCard
|
|
28
|
+
detail="In the last 30 days"
|
|
29
|
+
icon={GitPullRequest}
|
|
30
|
+
label="Created"
|
|
31
|
+
value={formatCompactNumber(summary.created)}
|
|
32
|
+
/>
|
|
33
|
+
<StatCard
|
|
34
|
+
detail="Share of completed changes that merged"
|
|
35
|
+
icon={LibraryBig}
|
|
36
|
+
label="Merge rate"
|
|
37
|
+
value={formatMergeRate(summary.mergeRate)}
|
|
38
|
+
/>
|
|
39
|
+
<StatCard
|
|
40
|
+
detail="Median time from open to merge in the last 30 days"
|
|
41
|
+
icon={Timer}
|
|
42
|
+
label="Median merge time"
|
|
43
|
+
value={formatDuration(summary.medianMergeTimeMs) || "—"}
|
|
44
|
+
/>
|
|
45
|
+
<StatCard
|
|
46
|
+
detail="Conversation cost for changes opened in the last 30 days"
|
|
47
|
+
icon={Coins}
|
|
48
|
+
label="Cost"
|
|
49
|
+
value={formatCodeCost(summary.costUsd)}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|