@sentry/junior-dashboard 0.258.0 → 0.260.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.
Files changed (65) hide show
  1. package/README.md +5 -3
  2. package/dist/app.js +88 -37
  3. package/dist/chunks/github-light-QHW3AEMR.js +1 -0
  4. package/dist/client.js +86 -50
  5. package/dist/index.js +88 -37
  6. package/dist/tailwind.css +1 -1
  7. package/package.json +3 -3
  8. package/src/client/App.tsx +0 -1
  9. package/src/client/code.tsx +10 -4
  10. package/src/client/components/ProfileMenu.tsx +3 -0
  11. package/src/client/components/README.md +12 -2
  12. package/src/client/components/ThemeSwitcher.tsx +48 -0
  13. package/src/client/components/charts/ActivityChart.tsx +6 -7
  14. package/src/client/components/charts/InputCacheChart.tsx +1 -1
  15. package/src/client/components/charts/SystemMetricCharts.tsx +3 -3
  16. package/src/client/components/charts/WorkspaceUsageChart.tsx +1 -1
  17. package/src/client/components/charts/cache-input.ts +11 -3
  18. package/src/client/components/layout/DashboardHeader.tsx +2 -9
  19. package/src/client/conversations/ConversationBrief.tsx +35 -9
  20. package/src/client/conversations/ConversationHomeList.tsx +7 -1
  21. package/src/client/conversations/ConversationMeta.tsx +19 -7
  22. package/src/client/conversations/ConversationTranscript.tsx +8 -11
  23. package/src/client/conversations/EventDetails.tsx +9 -5
  24. package/src/client/conversations/README.md +6 -0
  25. package/src/client/conversations/TranscriptContextEventView.tsx +5 -1
  26. package/src/client/conversations/TranscriptEventView.tsx +48 -9
  27. package/src/client/conversations/TranscriptMessageView.tsx +6 -1
  28. package/src/client/conversations/TranscriptStructuredEventView.tsx +4 -2
  29. package/src/client/conversations/TranscriptText.tsx +28 -20
  30. package/src/client/conversations/conversationActivityPreview.ts +6 -0
  31. package/src/client/conversations/eventTranscript.ts +4 -1
  32. package/src/client/conversations/transcriptMentions.tsx +27 -10
  33. package/src/client/pages/admin/AdminPage.tsx +14 -5
  34. package/src/client/pages/admin/ServiceAccountsSection.tsx +231 -0
  35. package/src/client/pages/automations/AutomationCostChart.tsx +2 -2
  36. package/src/client/pages/automations/AutomationDetailsDrawer.tsx +4 -1
  37. package/src/client/pages/automations/AutomationExecutionChart.tsx +2 -2
  38. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +3 -3
  39. package/src/client/pages/code/CodeActivityChart.tsx +4 -4
  40. package/src/client/pages/dev/ComponentsPage.tsx +7 -3
  41. package/src/client/pages/dev/ConversationFixture.tsx +29 -0
  42. package/src/client/pages/locations/LocationActivityChart.tsx +14 -4
  43. package/src/client/pages/locations/LocationDirectoryActivityChart.tsx +14 -9
  44. package/src/client/pages/memory/MemoryCostChart.tsx +5 -5
  45. package/src/client/pages/memory/MemoryDetailsDrawer.tsx +9 -2
  46. package/src/client/pages/memory/MemoryTimeline.tsx +2 -2
  47. package/src/client/pages/people/ContributionGrid.tsx +8 -8
  48. package/src/client/pages/people/PeopleActivityChart.tsx +23 -10
  49. package/src/client/pages/system/BaseSandboxCard.tsx +76 -0
  50. package/src/client/pages/system/ConversationActivityChart.tsx +2 -2
  51. package/src/client/pages/system/PluginBarChart.tsx +12 -6
  52. package/src/client/pages/system/SandboxSizeSummary.tsx +47 -0
  53. package/src/client/pages/system/WorkspaceDetails.tsx +11 -1
  54. package/src/client/pages/system/WorkspaceEditor.tsx +19 -0
  55. package/src/client/pages/system/WorkspaceFormPage.tsx +28 -19
  56. package/src/client/pages/system/WorkspaceList.tsx +6 -1
  57. package/src/client/pages/system/WorkspacesPage.tsx +18 -12
  58. package/src/client/pages/system/workspaceDraft.ts +8 -0
  59. package/src/client/theme.ts +52 -0
  60. package/src/client/types.ts +8 -1
  61. package/src/mock-reporting/fixtures.ts +48 -35
  62. package/src/shell.ts +4 -1
  63. package/src/tailwind.css +109 -0
  64. package/src/theme.ts +49 -0
  65. package/src/client/pages/system/BaselineSnapshotCard.tsx +0 -18
@@ -43,7 +43,13 @@ export function LocationActivityChart(props: {
43
43
  <ChartLegend
44
44
  ariaLabel="Conversation activity period"
45
45
  inline
46
- items={[{ color: "#22d3ee", key: "period", label: "90 days" }]}
46
+ items={[
47
+ {
48
+ color: "var(--color-cyan-400)",
49
+ key: "period",
50
+ label: "90 days",
51
+ },
52
+ ]}
47
53
  />
48
54
  }
49
55
  />
@@ -55,8 +61,12 @@ export function LocationActivityChart(props: {
55
61
  >
56
62
  <defs>
57
63
  <linearGradient id="location-bars" x1="0" x2="0" y1="0" y2="1">
58
- <stop offset="0%" stopColor="#22d3ee" />
59
- <stop offset="100%" stopColor="#f59e0b" stopOpacity="0.65" />
64
+ <stop offset="0%" stopColor="var(--color-cyan-400)" />
65
+ <stop
66
+ offset="100%"
67
+ stopColor="var(--color-amber-500)"
68
+ stopOpacity="0.65"
69
+ />
60
70
  </linearGradient>
61
71
  </defs>
62
72
  <ActivityChartGrid layout={layout} maximum={maximum} />
@@ -98,7 +108,7 @@ export function LocationActivityChart(props: {
98
108
  format={formatActivityChartAverage}
99
109
  layout={layout}
100
110
  maximum={maximum}
101
- stroke="#22d3ee"
111
+ stroke="var(--color-cyan-400)"
102
112
  />
103
113
  <ActivityChartDateLabels
104
114
  dates={props.days.map((day) => day.date)}
@@ -24,8 +24,7 @@ export function LocationDirectoryActivityChart(props: {
24
24
  days: LocationActivityDayReport[];
25
25
  }) {
26
26
  const bucketUnit = props.bucketUnit ?? "day";
27
- const perBucket =
28
- `Conversations per ${timeRangeBucketPerLabel(bucketUnit)}`;
27
+ const perBucket = `Conversations per ${timeRangeBucketPerLabel(bucketUnit)}`;
29
28
 
30
29
  const layout = createActivityChartLayout(260);
31
30
  const maximum = Math.max(
@@ -42,17 +41,23 @@ export function LocationDirectoryActivityChart(props: {
42
41
  return (
43
42
  <Card>
44
43
  <CardHeader
45
- description={
46
- `${timeRangeBucketAdjective(bucketUnit)} public volume compared with private activity in aggregate.`
47
- }
44
+ description={`${timeRangeBucketAdjective(bucketUnit)} public volume compared with private activity in aggregate.`}
48
45
  title={perBucket}
49
46
  trailing={
50
47
  <ChartLegend
51
48
  ariaLabel="Conversation visibility legend"
52
49
  inline
53
50
  items={[
54
- { color: "#22d3ee", key: "public", label: "public" },
55
- { color: "#fbbf24", key: "private", label: "private" },
51
+ {
52
+ color: "var(--color-cyan-400)",
53
+ key: "public",
54
+ label: "public",
55
+ },
56
+ {
57
+ color: "var(--color-amber-400)",
58
+ key: "private",
59
+ label: "private",
60
+ },
56
61
  ]}
57
62
  />
58
63
  }
@@ -70,13 +75,13 @@ export function LocationDirectoryActivityChart(props: {
70
75
  [
71
76
  {
72
77
  count: day.publicConversations,
73
- fill: "#22d3ee",
78
+ fill: "var(--color-cyan-400)",
74
79
  key: "public",
75
80
  x: groupX,
76
81
  },
77
82
  {
78
83
  count: day.privateConversations,
79
- fill: "#fbbf24",
84
+ fill: "var(--color-amber-400)",
80
85
  key: "private",
81
86
  x: groupX + barWidth + gap,
82
87
  },
@@ -74,13 +74,13 @@ export function MemoryCostChart(props: {
74
74
  ariaLabel="Memory cost legend"
75
75
  items={[
76
76
  {
77
- color: "#67e8f9",
77
+ color: "var(--color-cyan-300)",
78
78
  key: "extraction",
79
79
  label: "Extraction",
80
80
  value: formatCostSummary({ total: extractionTotal }),
81
81
  },
82
82
  {
83
- color: "#e879f9",
83
+ color: "var(--color-fuchsia-400)",
84
84
  key: "recall",
85
85
  label: "Recall",
86
86
  value: formatCostSummary({ total: recallTotal }),
@@ -130,7 +130,7 @@ export function MemoryCostChart(props: {
130
130
  >
131
131
  <g tabIndex={0}>
132
132
  <rect
133
- fill="#67e8f9"
133
+ fill="var(--color-cyan-300)"
134
134
  height={extractionHeight}
135
135
  opacity={day.extraction.costUsd > 0 ? 0.82 : 0.1}
136
136
  rx="1"
@@ -139,7 +139,7 @@ export function MemoryCostChart(props: {
139
139
  y={layout.top + layout.plotHeight - extractionHeight}
140
140
  />
141
141
  <rect
142
- fill="#e879f9"
142
+ fill="var(--color-fuchsia-400)"
143
143
  height={recallHeight}
144
144
  opacity={day.recall.costUsd > 0 ? 0.82 : 0.1}
145
145
  rx="1"
@@ -169,7 +169,7 @@ export function MemoryCostChart(props: {
169
169
  format={(value) => formatCostSummary({ total: value })}
170
170
  layout={layout}
171
171
  maximum={maximum}
172
- stroke="#e2e8f0"
172
+ stroke="var(--color-slate-200)"
173
173
  />
174
174
  <ActivityChartDateLabels
175
175
  dates={days.map((day) => day.date)}
@@ -10,6 +10,10 @@ import type {
10
10
  PluginUserPageRecordAction,
11
11
  } from "../user/pluginUserPageData";
12
12
 
13
+ // The memory plugin API does not resolve mentions yet, so memory text shows
14
+ // Slack ids instead of names.
15
+ const NO_MEMORY_MENTIONS: readonly [] = [];
16
+
13
17
  type MemoryActionMutation = UseMutationResult<
14
18
  void,
15
19
  Error,
@@ -86,10 +90,13 @@ export function MemoryDetailsDrawer(props: {
86
90
  <div className="mb-2 font-sans font-medium text-xs text-dashboard-text-muted">
87
91
  Memory
88
92
  </div>
89
- <TranscriptText text={record.title} />
93
+ <TranscriptText mentions={NO_MEMORY_MENTIONS} text={record.title} />
90
94
  {record.description ? (
91
95
  <div className="mt-3">
92
- <TranscriptText text={record.description} />
96
+ <TranscriptText
97
+ mentions={NO_MEMORY_MENTIONS}
98
+ text={record.description}
99
+ />
93
100
  </div>
94
101
  ) : null}
95
102
  </div>
@@ -15,8 +15,8 @@ import { Card } from "../../components/layout/Card";
15
15
  import type { MemoryDay } from "./memoryDashboard";
16
16
 
17
17
  const series = [
18
- { color: "#67e8f9", key: "personal", label: "Personal" },
19
- { color: "#6ee7b7", key: "public", label: "Public" },
18
+ { color: "var(--color-cyan-300)", key: "personal", label: "Personal" },
19
+ { color: "var(--color-emerald-300)", key: "public", label: "Public" },
20
20
  ] as const;
21
21
 
22
22
  /** Render viewer memory creation as a stacked personal/public timeline. */
@@ -124,10 +124,10 @@ export function ContributionGrid(props: { days: ContributionDay[] }) {
124
124
  className={cn(
125
125
  "size-3 border border-dashboard-overlay",
126
126
  level === 0 && "bg-dashboard-surface-hover",
127
- level === 1 && "bg-[#133225]",
128
- level === 2 && "bg-[#176a4a]",
129
- level === 3 && "bg-[#22a06b]",
130
- level === 4 && "bg-[#8bdc97]",
127
+ level === 1 && "bg-[#133225] light:bg-[#9be9a8]",
128
+ level === 2 && "bg-[#176a4a] light:bg-[#40c463]",
129
+ level === 3 && "bg-[#22a06b] light:bg-[#30a14e]",
130
+ level === 4 && "bg-[#8bdc97] light:bg-[#216e39]",
131
131
  )}
132
132
  key={level}
133
133
  />
@@ -156,8 +156,8 @@ function activityRuntime(day: ContributionDay): string {
156
156
  function activityClass(count: number, max: number): string {
157
157
  if (count <= 0) return "bg-dashboard-surface-hover";
158
158
  const ratio = count / max;
159
- if (ratio >= 0.75) return "bg-[#8bdc97]";
160
- if (ratio >= 0.45) return "bg-[#22a06b]";
161
- if (ratio >= 0.2) return "bg-[#176a4a]";
162
- return "bg-[#133225]";
159
+ if (ratio >= 0.75) return "bg-[#8bdc97] light:bg-[#216e39]";
160
+ if (ratio >= 0.45) return "bg-[#22a06b] light:bg-[#30a14e]";
161
+ if (ratio >= 0.2) return "bg-[#176a4a] light:bg-[#40c463]";
162
+ return "bg-[#133225] light:bg-[#9be9a8]";
163
163
  }
@@ -44,8 +44,7 @@ export function PeopleActivityChart(props: {
44
44
  days: PeopleActivityDayReport[];
45
45
  }) {
46
46
  const bucketUnit = props.bucketUnit ?? "day";
47
- const chartTitle =
48
- `Active people per ${timeRangeBucketPerLabel(bucketUnit)}`;
47
+ const chartTitle = `Active people per ${timeRangeBucketPerLabel(bucketUnit)}`;
49
48
 
50
49
  const layout = createActivityChartLayout(260);
51
50
  const values = props.days.map((day) => day.activePeople);
@@ -70,7 +69,13 @@ export function PeopleActivityChart(props: {
70
69
  <ChartLegend
71
70
  ariaLabel="People activity legend"
72
71
  inline
73
- items={[{ color: "#fbbf24", key: "people", label: "people" }]}
72
+ items={[
73
+ {
74
+ color: "var(--color-amber-400)",
75
+ key: "people",
76
+ label: "people",
77
+ },
78
+ ]}
74
79
  />
75
80
  }
76
81
  />
@@ -82,13 +87,21 @@ export function PeopleActivityChart(props: {
82
87
  >
83
88
  <defs>
84
89
  <linearGradient id="people-area" x1="0" x2="0" y1="0" y2="1">
85
- <stop offset="0%" stopColor="#f59e0b" stopOpacity="0.3" />
86
- <stop offset="100%" stopColor="#f59e0b" stopOpacity="0" />
90
+ <stop
91
+ offset="0%"
92
+ stopColor="var(--color-amber-500)"
93
+ stopOpacity="0.3"
94
+ />
95
+ <stop
96
+ offset="100%"
97
+ stopColor="var(--color-amber-500)"
98
+ stopOpacity="0"
99
+ />
87
100
  </linearGradient>
88
101
  <linearGradient id="people-line" x1="0" x2="1" y1="0" y2="0">
89
- <stop offset="0%" stopColor="#f59e0b" />
90
- <stop offset="55%" stopColor="#fbbf24" />
91
- <stop offset="100%" stopColor="#22d3ee" />
102
+ <stop offset="0%" stopColor="var(--color-amber-500)" />
103
+ <stop offset="55%" stopColor="var(--color-amber-400)" />
104
+ <stop offset="100%" stopColor="var(--color-cyan-400)" />
92
105
  </linearGradient>
93
106
  </defs>
94
107
  <ActivityChartGrid layout={layout} maximum={maximum} />
@@ -122,7 +135,7 @@ export function PeopleActivityChart(props: {
122
135
  <circle
123
136
  cx={point.x}
124
137
  cy={point.y}
125
- fill="#fbbf24"
138
+ fill="var(--color-amber-400)"
126
139
  opacity={props.days.length > 30 ? 0.35 : 0.75}
127
140
  r={props.days.length > 30 ? 3 : 4}
128
141
  tabIndex={0}
@@ -136,7 +149,7 @@ export function PeopleActivityChart(props: {
136
149
  format={formatActivityChartAverage}
137
150
  layout={layout}
138
151
  maximum={maximum}
139
- stroke="#fbbf24"
152
+ stroke="var(--color-amber-400)"
140
153
  />
141
154
  <ActivityChartDateLabels
142
155
  dates={props.days.map((day) => day.date)}
@@ -0,0 +1,76 @@
1
+ import type { ReactNode } from "react";
2
+ import type { BaselineSnapshotReport } from "@sentry/junior/api/schema";
3
+
4
+ import { Card } from "../../components/layout/Card";
5
+ import { formatMs, formatRelativeTime, formatTime } from "../../format";
6
+ import { formatSandboxSize } from "./SandboxSizeSummary";
7
+
8
+ /**
9
+ * Show the base Sandbox: the default size and the baseline snapshot.
10
+ * Workspaces inherit the base setup and use the default size unless they set
11
+ * vCPUs.
12
+ */
13
+ export function BaseSandboxCard(props: {
14
+ defaultVcpus: number | null;
15
+ snapshot: BaselineSnapshotReport | null;
16
+ }) {
17
+ const snapshot = props.snapshot;
18
+ return (
19
+ <Card as="section" className="mb-0" padding="md" variant="section">
20
+ <div aria-labelledby="base-sandbox-heading" className="grid gap-4">
21
+ <div className="min-w-0 grid gap-1">
22
+ <h2
23
+ className="m-0 text-base font-semibold text-dashboard-text"
24
+ id="base-sandbox-heading"
25
+ >
26
+ Base Sandbox
27
+ </h2>
28
+ <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
29
+ The base snapshot is inherited by all Workspaces.
30
+ </p>
31
+ </div>
32
+
33
+ <dl className="m-0 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-6 gap-y-2 text-sm">
34
+ <Fact label="Size">{formatSandboxSize(props.defaultVcpus)}</Fact>
35
+ {snapshot ? (
36
+ <>
37
+ {snapshot.dependencyCount == null ? null : (
38
+ <Fact label="Dependencies">{snapshot.dependencyCount}</Fact>
39
+ )}
40
+ <Fact label="Build time">
41
+ {formatMs(snapshot.buildDurationMs)}
42
+ </Fact>
43
+ <Fact label="Generated">
44
+ <span title={formatTime(snapshot.generatedAt)}>
45
+ {formatRelativeTime(snapshot.generatedAt)}
46
+ </span>
47
+ </Fact>
48
+ <Fact label="Snapshot">
49
+ <span className="break-all font-mono text-xs">
50
+ {snapshot.id}
51
+ </span>
52
+ </Fact>
53
+ </>
54
+ ) : (
55
+ <Fact label="Snapshot">
56
+ <span className="text-dashboard-text-muted">
57
+ Not built yet. The first Sandbox without a Workspace builds it.
58
+ </span>
59
+ </Fact>
60
+ )}
61
+ </dl>
62
+ </div>
63
+ </Card>
64
+ );
65
+ }
66
+
67
+ function Fact(props: { children: ReactNode; label: string }) {
68
+ return (
69
+ <div className="contents">
70
+ <dt className="text-dashboard-text-muted">{props.label}</dt>
71
+ <dd className="m-0 min-w-0 tabular-nums text-dashboard-text">
72
+ {props.children}
73
+ </dd>
74
+ </div>
75
+ );
76
+ }
@@ -65,7 +65,7 @@ export function ConversationActivityChart(props: {
65
65
  >
66
66
  <g tabIndex={0}>
67
67
  <rect
68
- fill="#22d3ee"
68
+ fill="var(--color-cyan-400)"
69
69
  height={renderedHeight}
70
70
  opacity={day.conversations ? 0.78 : 0.08}
71
71
  rx="2"
@@ -89,7 +89,7 @@ export function ConversationActivityChart(props: {
89
89
  format={formatActivityChartAverage}
90
90
  layout={layout}
91
91
  maximum={maximum}
92
- stroke="#22d3ee"
92
+ stroke="var(--color-cyan-400)"
93
93
  unit={timeRangeBucketAverageUnit(bucketUnit)}
94
94
  />
95
95
  <ActivityChartDateLabels
@@ -15,12 +15,18 @@ import { formatCostSummary } from "../../format";
15
15
 
16
16
  type Widget = NonNullable<PluginOperationalReport["widgets"]>[number];
17
17
 
18
- const colors = ["#67e8f9", "#6ee7b7", "#fbbf24", "#fb7185", "#a78bfa"] as const;
18
+ const colors = [
19
+ "var(--color-cyan-300)",
20
+ "var(--color-emerald-300)",
21
+ "var(--color-amber-400)",
22
+ "var(--color-rose-400)",
23
+ "var(--color-violet-400)",
24
+ ] as const;
19
25
  const toneColors = {
20
- danger: "#fb7185",
21
- good: "#6ee7b7",
22
- neutral: "#67e8f9",
23
- warning: "#fbbf24",
26
+ danger: "var(--color-rose-400)",
27
+ good: "var(--color-emerald-300)",
28
+ neutral: "var(--color-cyan-300)",
29
+ warning: "var(--color-amber-400)",
24
30
  } as const;
25
31
 
26
32
  function seriesColor(series: Widget["series"][number], index: number): string {
@@ -97,7 +103,7 @@ export function PluginBarChart(props: {
97
103
  return (
98
104
  <g key={index}>
99
105
  <line
100
- stroke="rgba(255,255,255,0.07)"
106
+ stroke="var(--color-dashboard-border-subtle)"
101
107
  strokeDasharray="3 5"
102
108
  x1={layout.left}
103
109
  x2={layout.width - layout.right}
@@ -0,0 +1,47 @@
1
+ import { Cpu } from "lucide-react";
2
+
3
+ import { Detail, DetailList } from "../../components/DetailList";
4
+ import { Card } from "../../components/layout/Card";
5
+
6
+ /** Vercel Sandbox gives each vCPU 2 GB of memory. */
7
+ const MEMORY_GB_PER_VCPU = 2;
8
+
9
+ /** Format a Sandbox vCPU count with its memory. Null means the Vercel default. */
10
+ export function formatSandboxSize(vcpus: number | null): string {
11
+ if (vcpus === null) return "Vercel default";
12
+ return `${vcpus} vCPU${vcpus === 1 ? "" : "s"} · ${vcpus * MEMORY_GB_PER_VCPU} GB`;
13
+ }
14
+
15
+ /** Show one Sandbox size in the shared System summary surface. */
16
+ export function SandboxSizeSummary(props: {
17
+ description: string;
18
+ headingId: string;
19
+ title: string;
20
+ value: string;
21
+ }) {
22
+ return (
23
+ <Card as="section" className="mb-0" padding="md" variant="section">
24
+ <div aria-labelledby={props.headingId} className="grid gap-4">
25
+ <div className="min-w-0 grid gap-1">
26
+ <h2
27
+ className="m-0 text-base font-semibold text-dashboard-text"
28
+ id={props.headingId}
29
+ >
30
+ {props.title}
31
+ </h2>
32
+ <p className="m-0 text-sm leading-relaxed text-dashboard-text-muted">
33
+ {props.description}
34
+ </p>
35
+ </div>
36
+ <DetailList>
37
+ <Detail label="Size">
38
+ <span className="inline-flex items-center gap-2">
39
+ <Cpu aria-hidden="true" size={14} />
40
+ {props.value}
41
+ </span>
42
+ </Detail>
43
+ </DetailList>
44
+ </div>
45
+ </Card>
46
+ );
47
+ }
@@ -6,16 +6,19 @@ import { WorkspaceUsageChart } from "../../components/charts/WorkspaceUsageChart
6
6
  import { workspaceUsageDays } from "../../components/charts/workspaceUsage";
7
7
  import type { TimeRangeDays } from "../../components/controls/TimeRangeSelector";
8
8
  import { Card } from "../../components/layout/Card";
9
+ import { formatSandboxSize, SandboxSizeSummary } from "./SandboxSizeSummary";
9
10
  import { SnapshotSummary } from "./SnapshotSummary";
10
11
 
11
12
  /** Load Workspace usage stats and render snapshot plus switch volume. */
12
13
  export function WorkspaceDetails(props: {
14
+ defaultVcpus: number | null;
13
15
  range: TimeRangeDays;
14
16
  workspace: WorkspaceReport;
15
17
  }) {
16
18
  const statsQuery = useStatsData();
17
19
  return (
18
20
  <WorkspaceDetailsContent
21
+ defaultVcpus={props.defaultVcpus}
19
22
  error={Boolean(statsQuery.error)}
20
23
  loading={statsQuery.isPending}
21
24
  range={props.range}
@@ -25,8 +28,9 @@ export function WorkspaceDetails(props: {
25
28
  );
26
29
  }
27
30
 
28
- /** Present snapshot metadata and switch volume for one Workspace recipe. */
31
+ /** Present snapshot metadata, Sandbox size, and switch volume for one Workspace recipe. */
29
32
  export function WorkspaceDetailsContent(props: {
33
+ defaultVcpus: number | null;
30
34
  error: boolean;
31
35
  loading: boolean;
32
36
  range: TimeRangeDays;
@@ -42,6 +46,12 @@ export function WorkspaceDetailsContent(props: {
42
46
  snapshot={props.workspace.snapshot}
43
47
  title="Current snapshot"
44
48
  />
49
+ <SandboxSizeSummary
50
+ description="Sandboxes for this Workspace start with this size."
51
+ headingId="workspace-sandbox-size-heading"
52
+ title="Sandbox size"
53
+ value={formatSandboxSize(props.workspace.vcpus ?? props.defaultVcpus)}
54
+ />
45
55
  {!props.stats ? (
46
56
  <Card padding="sm">
47
57
  <EmptyTelemetry>
@@ -6,6 +6,7 @@ import { Field } from "../../components/Field";
6
6
  import { InlineError } from "../../components/InlineError";
7
7
  import { TextArea, TextInput } from "../../components/TextInput";
8
8
  import { Card } from "../../components/layout/Card";
9
+ import { formatSandboxSize } from "./SandboxSizeSummary";
9
10
  import {
10
11
  createRepoDraft,
11
12
  type RepoDraft,
@@ -15,6 +16,7 @@ import {
15
16
  type WorkspaceEditorProps = {
16
17
  busy: boolean;
17
18
  canSave: boolean;
19
+ defaultVcpus: number | null;
18
20
  draft: WorkspaceDraft;
19
21
  error?: string;
20
22
  editing: boolean;
@@ -200,6 +202,23 @@ export function WorkspaceEditor(props: WorkspaceEditorProps) {
200
202
  />
201
203
  </Field>
202
204
 
205
+ <Field
206
+ help="Sandbox vCPUs for this Workspace. Each vCPU adds 2 GB of memory. Leave empty to use the app default."
207
+ htmlFor="workspace-vcpus"
208
+ label="vCPUs"
209
+ >
210
+ <TextInput
211
+ autoComplete="off"
212
+ id="workspace-vcpus"
213
+ inputMode="numeric"
214
+ onChange={(event) =>
215
+ props.onChange({ ...props.draft, vcpus: event.target.value })
216
+ }
217
+ placeholder={formatSandboxSize(props.defaultVcpus)}
218
+ value={props.draft.vcpus}
219
+ />
220
+ </Field>
221
+
203
222
  {props.error ? <InlineError>{props.error}</InlineError> : null}
204
223
 
205
224
  <div className="flex flex-wrap items-center gap-3 border-t border-dashboard-border-subtle pt-5">
@@ -2,9 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import { useEffect, useState } from "react";
3
3
  import { useNavigate, useParams } from "react-router";
4
4
  import {
5
+ workspaceListSchema,
5
6
  workspaceSchema,
6
- type BaselineSnapshotReport,
7
- type WorkspaceReport,
7
+ type WorkspaceListReport,
8
8
  } from "@sentry/junior/api/schema";
9
9
 
10
10
  import { EmptyTelemetry } from "../../components/EmptyTelemetry";
@@ -16,10 +16,7 @@ import { fetchDashboardJson, post, put } from "../../http";
16
16
  import { SystemPageLayout } from "./SystemPageLayout";
17
17
  import { WorkspaceDetails } from "./WorkspaceDetails";
18
18
  import { WorkspaceEditor } from "./WorkspaceEditor";
19
- import {
20
- readWorkspaceApiError,
21
- workspacesQueryKey,
22
- } from "./WorkspacesPage";
19
+ import { readWorkspaceApiError, workspacesQueryKey } from "./WorkspacesPage";
23
20
  import {
24
21
  canSaveWorkspaceDraft,
25
22
  createWorkspaceDraft,
@@ -56,6 +53,14 @@ export function WorkspaceFormPage() {
56
53
  ),
57
54
  retry: false,
58
55
  });
56
+ // Shares the list cache so the default size is usually loaded already.
57
+ const workspacesQuery = useQuery({
58
+ queryKey: workspacesQueryKey,
59
+ queryFn: ({ signal }) =>
60
+ fetchDashboardJson(workspaceListSchema, "/api/workspaces", signal),
61
+ retry: false,
62
+ });
63
+ const defaultVcpus = workspacesQuery.data?.defaultVcpus ?? null;
59
64
 
60
65
  // Seed the edit draft once per Workspace id. editWorkspaceDraft assigns fresh
61
66
  // repo keys, so calling it during every query-driven render remounts fields.
@@ -87,18 +92,17 @@ export function WorkspaceFormPage() {
87
92
  },
88
93
  onSuccess: async (workspace) => {
89
94
  await queryClient.cancelQueries({ queryKey: workspacesQueryKey });
90
- queryClient.setQueryData<{
91
- baselineSnapshot: BaselineSnapshotReport | null;
92
- workspaces: WorkspaceReport[];
93
- }>(workspacesQueryKey, (current) => ({
94
- baselineSnapshot: current?.baselineSnapshot ?? null,
95
- workspaces: [
96
- workspace,
97
- ...(current?.workspaces ?? []).filter(
98
- (item) => item.id !== workspace.id,
99
- ),
100
- ].sort((left, right) => left.name.localeCompare(right.name)),
101
- }));
95
+ queryClient.setQueryData<WorkspaceListReport>(
96
+ workspacesQueryKey,
97
+ (current) =>
98
+ current && {
99
+ ...current,
100
+ workspaces: [
101
+ workspace,
102
+ ...current.workspaces.filter((item) => item.id !== workspace.id),
103
+ ].sort((left, right) => left.name.localeCompare(right.name)),
104
+ },
105
+ );
102
106
  navigate("/system/workspaces");
103
107
  },
104
108
  });
@@ -135,7 +139,11 @@ export function WorkspaceFormPage() {
135
139
  />
136
140
 
137
141
  {editing && workspaceQuery.data ? (
138
- <WorkspaceDetails range={range} workspace={workspaceQuery.data} />
142
+ <WorkspaceDetails
143
+ defaultVcpus={defaultVcpus}
144
+ range={range}
145
+ workspace={workspaceQuery.data}
146
+ />
139
147
  ) : null}
140
148
 
141
149
  {workspaceQuery.error ? (
@@ -150,6 +158,7 @@ export function WorkspaceFormPage() {
150
158
  ) : draft ? (
151
159
  <WorkspaceEditor
152
160
  busy={saveMutation.isPending}
161
+ defaultVcpus={defaultVcpus}
153
162
  canSave={canSaveWorkspaceDraft(draft, saveMutation.isPending)}
154
163
  draft={draft}
155
164
  editing={editing}
@@ -6,9 +6,11 @@ import { Button } from "../../components/Button";
6
6
  import { EmptyTelemetry } from "../../components/EmptyTelemetry";
7
7
  import { SelectableRow } from "../../components/SelectableRow";
8
8
  import { Card } from "../../components/layout/Card";
9
+ import { formatSandboxSize } from "./SandboxSizeSummary";
9
10
 
10
11
  type WorkspaceListProps = {
11
12
  busy: boolean;
13
+ defaultVcpus: number | null;
12
14
  onDelete(workspace: WorkspaceReport): void;
13
15
  workspaces: WorkspaceReport[];
14
16
  };
@@ -29,6 +31,7 @@ export function WorkspaceList(props: WorkspaceListProps) {
29
31
  {props.workspaces.map((workspace) => (
30
32
  <WorkspaceListItem
31
33
  busy={props.busy}
34
+ defaultVcpus={props.defaultVcpus}
32
35
  key={workspace.id}
33
36
  onDelete={props.onDelete}
34
37
  workspace={workspace}
@@ -42,6 +45,7 @@ export function WorkspaceList(props: WorkspaceListProps) {
42
45
 
43
46
  function WorkspaceListItem(props: {
44
47
  busy: boolean;
48
+ defaultVcpus: number | null;
45
49
  onDelete(workspace: WorkspaceReport): void;
46
50
  workspace: WorkspaceReport;
47
51
  }) {
@@ -72,7 +76,8 @@ function WorkspaceListItem(props: {
72
76
  </span>
73
77
  <span className="font-mono text-xs text-dashboard-text-muted">
74
78
  {workspace.repos.length} repo
75
- {workspace.repos.length === 1 ? "" : "s"}
79
+ {workspace.repos.length === 1 ? "" : "s"} ·{" "}
80
+ {formatSandboxSize(workspace.vcpus ?? props.defaultVcpus)}
76
81
  </span>
77
82
  </span>
78
83
  <span className="mt-1.5 block truncate font-mono text-xs text-dashboard-text-muted">