@sentry/junior-dashboard 0.58.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 (89) hide show
  1. package/LICENSE +201 -0
  2. package/dist/app.d.ts +22 -0
  3. package/dist/app.js +492 -0
  4. package/dist/auth.d.ts +30 -0
  5. package/dist/client/App.d.ts +2 -0
  6. package/dist/client/api.d.ts +10 -0
  7. package/dist/client/code.d.ts +15 -0
  8. package/dist/client/components/CommandRail.d.ts +6 -0
  9. package/dist/client/components/ConversationList.d.ts +7 -0
  10. package/dist/client/components/ConversationRowStats.d.ts +6 -0
  11. package/dist/client/components/ConversationStack.d.ts +5 -0
  12. package/dist/client/components/ConversationSummary.d.ts +5 -0
  13. package/dist/client/components/EmptyTelemetry.d.ts +5 -0
  14. package/dist/client/components/FilterTabs.d.ts +6 -0
  15. package/dist/client/components/JuniorLogo.d.ts +2 -0
  16. package/dist/client/components/LoadingView.d.ts +4 -0
  17. package/dist/client/components/Section.d.ts +6 -0
  18. package/dist/client/components/SectionHeader.d.ts +6 -0
  19. package/dist/client/components/SectionTitle.d.ts +5 -0
  20. package/dist/client/components/StatusBadge.d.ts +6 -0
  21. package/dist/client/components/ToolFrame.d.ts +10 -0
  22. package/dist/client/components/Transcript.d.ts +5 -0
  23. package/dist/client/components/TranscriptHeader.d.ts +7 -0
  24. package/dist/client/components/TranscriptLoading.d.ts +2 -0
  25. package/dist/client/components/TranscriptText.d.ts +6 -0
  26. package/dist/client/components/TranscriptToolView.d.ts +9 -0
  27. package/dist/client/components/TranscriptTurn.d.ts +8 -0
  28. package/dist/client/components/TurnDurationChart.d.ts +6 -0
  29. package/dist/client/components/statusStyles.d.ts +3 -0
  30. package/dist/client/components/transcriptPreview.d.ts +4 -0
  31. package/dist/client/components/transcriptRenderModel.d.ts +30 -0
  32. package/dist/client/components/transcriptStyles.d.ts +4 -0
  33. package/dist/client/format.d.ts +64 -0
  34. package/dist/client/pages/CommandCenter.d.ts +6 -0
  35. package/dist/client/pages/ConversationPage.d.ts +5 -0
  36. package/dist/client/pages/ConversationsPage.d.ts +5 -0
  37. package/dist/client/styles.d.ts +2 -0
  38. package/dist/client/types.d.ts +153 -0
  39. package/dist/client.d.ts +7 -0
  40. package/dist/client.js +56012 -0
  41. package/dist/config.d.ts +4 -0
  42. package/dist/handler.d.ts +2 -0
  43. package/dist/handler.js +563 -0
  44. package/dist/nitro.d.ts +16 -0
  45. package/dist/nitro.js +80 -0
  46. package/dist/tailwind.css +2 -0
  47. package/package.json +60 -0
  48. package/src/app.ts +421 -0
  49. package/src/auth.ts +202 -0
  50. package/src/client/App.tsx +175 -0
  51. package/src/client/api.ts +99 -0
  52. package/src/client/code.tsx +139 -0
  53. package/src/client/components/CommandRail.tsx +74 -0
  54. package/src/client/components/ConversationList.tsx +94 -0
  55. package/src/client/components/ConversationRowStats.tsx +21 -0
  56. package/src/client/components/ConversationStack.tsx +67 -0
  57. package/src/client/components/ConversationSummary.tsx +40 -0
  58. package/src/client/components/EmptyTelemetry.tsx +11 -0
  59. package/src/client/components/FilterTabs.tsx +35 -0
  60. package/src/client/components/JuniorLogo.tsx +8 -0
  61. package/src/client/components/LoadingView.tsx +16 -0
  62. package/src/client/components/Section.tsx +17 -0
  63. package/src/client/components/SectionHeader.tsx +16 -0
  64. package/src/client/components/SectionTitle.tsx +10 -0
  65. package/src/client/components/StatusBadge.tsx +40 -0
  66. package/src/client/components/ToolFrame.tsx +52 -0
  67. package/src/client/components/Transcript.tsx +39 -0
  68. package/src/client/components/TranscriptHeader.tsx +52 -0
  69. package/src/client/components/TranscriptLoading.tsx +10 -0
  70. package/src/client/components/TranscriptText.tsx +45 -0
  71. package/src/client/components/TranscriptToolView.tsx +207 -0
  72. package/src/client/components/TranscriptTurn.tsx +487 -0
  73. package/src/client/components/TurnDurationChart.tsx +349 -0
  74. package/src/client/components/statusStyles.ts +9 -0
  75. package/src/client/components/transcriptPreview.ts +20 -0
  76. package/src/client/components/transcriptRenderModel.ts +193 -0
  77. package/src/client/components/transcriptStyles.ts +11 -0
  78. package/src/client/format.ts +628 -0
  79. package/src/client/pages/CommandCenter.tsx +37 -0
  80. package/src/client/pages/ConversationPage.tsx +145 -0
  81. package/src/client/pages/ConversationsPage.tsx +59 -0
  82. package/src/client/styles.ts +6 -0
  83. package/src/client/types.ts +153 -0
  84. package/src/client.tsx +79 -0
  85. package/src/config.ts +72 -0
  86. package/src/handler.ts +26 -0
  87. package/src/nitro.ts +110 -0
  88. package/src/tailwind.css +13 -0
  89. package/src/virtual-modules.d.ts +5 -0
@@ -0,0 +1,145 @@
1
+ import { useParams } from "react-router";
2
+
3
+ import { useConversationData } from "../api";
4
+ import { StatusBadge } from "../components/StatusBadge";
5
+ import {
6
+ buildConversations,
7
+ conversationDisplayTitle,
8
+ formatConversationDuration,
9
+ formatRelativeTime,
10
+ formatTime,
11
+ slackLocationLabel,
12
+ turnMessageCount,
13
+ turnToolCallCount,
14
+ visualStatusForConversation,
15
+ } from "../format";
16
+ import { Transcript } from "../components/Transcript";
17
+ import { TranscriptLoading } from "../components/TranscriptLoading";
18
+ import type {
19
+ Conversation,
20
+ ConversationDetailFeed,
21
+ DashboardData,
22
+ } from "../types";
23
+
24
+ /** Render one permalinkable conversation transcript route. */
25
+ export function ConversationPage(props: { data?: DashboardData }) {
26
+ const routeParams = useParams();
27
+ const conversationId = routeParams.conversationId
28
+ ? decodeURIComponent(routeParams.conversationId)
29
+ : undefined;
30
+ const sessions = props.data?.sessions.sessions ?? [];
31
+ const conversations = buildConversations(sessions);
32
+ const conversation = conversations.find((item) => item.id === conversationId);
33
+ const detail = useConversationData(conversationId);
34
+ const visualStatus = conversation
35
+ ? visualStatusForConversation(conversation)
36
+ : undefined;
37
+
38
+ return (
39
+ <div className="min-w-0 px-4 py-5 md:px-8">
40
+ <section className="min-w-0">
41
+ <header className="mb-6 grid gap-3 border-l-4 border-[#beaaff]/70 pl-4 md:grid-cols-[minmax(0,1fr)_auto]">
42
+ <div className="min-w-0">
43
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
44
+ <h2 className="m-0 text-2xl font-bold leading-tight tracking-normal">
45
+ {conversationDisplayTitle(conversation)}
46
+ </h2>
47
+ <StatusBadge status={visualStatus} />
48
+ </div>
49
+ <div className="mt-1 break-words text-[0.88rem] leading-relaxed text-[#b8b8b8]">
50
+ <ConversationIdentity
51
+ conversation={conversation}
52
+ conversationId={conversationId}
53
+ />
54
+ </div>
55
+ </div>
56
+ <div className="self-start break-words text-[0.82rem] leading-relaxed text-[#b8b8b8] md:text-right">
57
+ updated{" "}
58
+ {formatRelativeTime(
59
+ conversation?.lastSeenAt ?? detail.data?.generatedAt,
60
+ )}
61
+ </div>
62
+ <ConversationStats conversation={conversation} detail={detail.data} />
63
+ </header>
64
+
65
+ {detail.isPending ? (
66
+ <TranscriptLoading />
67
+ ) : detail.error ? (
68
+ <div className="border border-white/10 bg-[#050505] p-4 text-[0.9rem] leading-relaxed text-[#b8b8b8]">
69
+ {detail.error.message}
70
+ </div>
71
+ ) : (
72
+ <Transcript turns={detail.data?.turns ?? []} />
73
+ )}
74
+ </section>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function ConversationIdentity(props: {
80
+ conversation: Conversation | undefined;
81
+ conversationId: string | undefined;
82
+ }) {
83
+ const id = props.conversationId ?? "missing conversation id";
84
+ const owner =
85
+ props.conversation?.requesterIdentity?.email ??
86
+ props.conversation?.requester ??
87
+ props.conversation?.requesterIdentity?.slackUserName;
88
+ return (
89
+ <>
90
+ {owner ? `${owner} · ` : ""}
91
+ {id}
92
+ {props.conversation?.sentryConversationUrl ? (
93
+ <>
94
+ {" · "}
95
+ <a
96
+ className="text-white no-underline hover:underline"
97
+ href={props.conversation.sentryConversationUrl}
98
+ rel="noreferrer"
99
+ target="_blank"
100
+ >
101
+ View in Sentry
102
+ </a>
103
+ </>
104
+ ) : null}
105
+ </>
106
+ );
107
+ }
108
+
109
+ function ConversationStats(props: {
110
+ conversation: Conversation | undefined;
111
+ detail?: ConversationDetailFeed;
112
+ }) {
113
+ if (!props.conversation) return null;
114
+ const messages = props.detail
115
+ ? props.detail.turns.reduce(
116
+ (count, turn) => count + turnMessageCount(turn),
117
+ 0,
118
+ )
119
+ : undefined;
120
+ const toolCalls = props.detail
121
+ ? props.detail.turns.reduce(
122
+ (count, turn) => count + turnToolCallCount(turn),
123
+ 0,
124
+ )
125
+ : undefined;
126
+ const stats = [
127
+ slackLocationLabel(props.conversation, { includeId: false }),
128
+ `${props.conversation.turns.length} turns`,
129
+ messages === undefined ? "messages loading" : `${messages} messages`,
130
+ toolCalls === undefined ? "tool calls loading" : `${toolCalls} tool calls`,
131
+ formatConversationDuration(props.conversation),
132
+ `started ${formatTime(props.conversation.startedAt)}`,
133
+ ].filter(Boolean);
134
+
135
+ return (
136
+ <div className="col-span-full flex flex-wrap gap-x-3 gap-y-1 break-words text-[0.76rem] leading-[1.45] text-[#888]">
137
+ {stats.map((value, index) => (
138
+ <span key={`${index}-${value}`}>
139
+ {index > 0 ? <span className="mr-3 text-[#666]">·</span> : null}
140
+ {value}
141
+ </span>
142
+ ))}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,59 @@
1
+ import { useSearchParams } from "react-router";
2
+
3
+ import { ConversationList } from "../components/ConversationList";
4
+ import { FilterTabs } from "../components/FilterTabs";
5
+ import { Section } from "../components/Section";
6
+ import { SectionHeader } from "../components/SectionHeader";
7
+ import { SectionTitle } from "../components/SectionTitle";
8
+ import {
9
+ buildConversations,
10
+ filterConversations,
11
+ formatTime,
12
+ getFilter,
13
+ } from "../format";
14
+ import type { DashboardData, SessionFilter } from "../types";
15
+
16
+ /** Render the searchable conversation index from recent turn summaries. */
17
+ export function ConversationsPage(props: { data?: DashboardData }) {
18
+ const [params, setParams] = useSearchParams();
19
+ const filter = getFilter(params.get("filter"));
20
+ const sessions = props.data?.sessions.sessions ?? [];
21
+ const conversations = buildConversations(sessions);
22
+ const visibleConversations = filterConversations(conversations, filter);
23
+ const search = params.toString();
24
+ const feedMeta =
25
+ props.data?.sessions.source === "turn_session_records"
26
+ ? `${conversations.length} conversations / ${sessions.length} turns / ${formatTime(props.data.sessions.generatedAt)}`
27
+ : "waiting for run history feed";
28
+
29
+ function updateFilter(nextFilter: SessionFilter) {
30
+ const next = new URLSearchParams(params);
31
+ next.set("filter", nextFilter);
32
+ setParams(next);
33
+ }
34
+
35
+ return (
36
+ <div className="min-w-0 px-4 py-4 md:px-8">
37
+ <section className="min-w-0">
38
+ <Section>
39
+ <SectionHeader
40
+ actions={<FilterTabs current={filter} onChange={updateFilter} />}
41
+ >
42
+ <div>
43
+ <SectionTitle>Conversations</SectionTitle>
44
+ <div className="mt-1 break-words text-[0.82rem] leading-relaxed text-[#b8b8b8]">
45
+ {feedMeta}
46
+ </div>
47
+ </div>
48
+ </SectionHeader>
49
+ <div>
50
+ <ConversationList
51
+ conversations={visibleConversations}
52
+ search={search ? `?${search}` : ""}
53
+ />
54
+ </div>
55
+ </Section>
56
+ </section>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,6 @@
1
+ /** Join component-owned Tailwind classes without pulling in a styling dependency. */
2
+ export function cn(
3
+ ...classes: Array<string | false | null | undefined>
4
+ ): string {
5
+ return classes.filter(Boolean).join(" ");
6
+ }
@@ -0,0 +1,153 @@
1
+ import type { BundledLanguage } from "shiki/bundle/web";
2
+
3
+ export type Health = { service: string; status: string; timestamp: string };
4
+
5
+ export type Runtime = {
6
+ cwd: string;
7
+ descriptionText?: string;
8
+ homeDir: string;
9
+ packagedContent: { packageNames: string[] };
10
+ };
11
+
12
+ export type Plugin = { name: string };
13
+
14
+ export type Skill = { name: string; pluginProvider?: string };
15
+
16
+ export type RequesterIdentity = {
17
+ email?: string;
18
+ fullName?: string;
19
+ slackUserId?: string;
20
+ slackUserName?: string;
21
+ };
22
+
23
+ export type TurnUsage = {
24
+ cachedInputTokens?: number;
25
+ cacheCreationTokens?: number;
26
+ inputTokens?: number;
27
+ outputTokens?: number;
28
+ totalTokens?: number;
29
+ };
30
+
31
+ export type Session = {
32
+ channel?: string;
33
+ channelName?: string;
34
+ conversationId?: string;
35
+ conversationTitle?: string;
36
+ cumulativeDurationMs?: number;
37
+ cumulativeUsage?: TurnUsage;
38
+ id: string;
39
+ lastProgressAt?: string;
40
+ lastSeenAt?: string;
41
+ requester?: string;
42
+ requesterIdentity?: RequesterIdentity;
43
+ sentryConversationUrl?: string;
44
+ sentryTraceUrl?: string;
45
+ startedAt?: string;
46
+ status: string;
47
+ surface?: string;
48
+ title?: string;
49
+ traceId?: string;
50
+ };
51
+
52
+ export type TranscriptPart = {
53
+ bytes?: number;
54
+ chars?: number;
55
+ id?: string;
56
+ input?: unknown;
57
+ inputKeys?: string[];
58
+ inputSizeBytes?: number;
59
+ inputSizeChars?: number;
60
+ inputType?: string;
61
+ name?: string;
62
+ output?: unknown;
63
+ outputKeys?: string[];
64
+ outputSizeBytes?: number;
65
+ outputSizeChars?: number;
66
+ outputType?: string;
67
+ redacted?: boolean;
68
+ text?: string;
69
+ type: string;
70
+ };
71
+
72
+ export type TranscriptMessage = {
73
+ parts: TranscriptPart[];
74
+ role: string;
75
+ timestamp?: number;
76
+ };
77
+
78
+ export type ConversationTurn = Session & {
79
+ transcript: TranscriptMessage[];
80
+ transcriptAvailable: boolean;
81
+ transcriptMetadata?: TranscriptMessage[];
82
+ transcriptMessageCount?: number;
83
+ transcriptRedacted?: boolean;
84
+ transcriptRedactionReason?: "non_public_conversation";
85
+ };
86
+
87
+ export type ConversationDetailFeed = {
88
+ conversationId: string;
89
+ generatedAt: string;
90
+ turns: ConversationTurn[];
91
+ };
92
+
93
+ export type Conversation = {
94
+ channel?: string;
95
+ channelName?: string;
96
+ conversationTitle?: string;
97
+ id: string;
98
+ lastProgressAt?: string;
99
+ lastSeenAt?: string;
100
+ requester?: string;
101
+ requesterIdentity?: RequesterIdentity;
102
+ sentryConversationUrl?: string;
103
+ sentryTraceUrl?: string;
104
+ startedAt?: string;
105
+ status: Session["status"];
106
+ surface?: string;
107
+ title: string;
108
+ traceId?: string;
109
+ turns: Session[];
110
+ };
111
+
112
+ export type SessionFeed = {
113
+ generatedAt?: string;
114
+ sessions: Session[];
115
+ source: string;
116
+ };
117
+
118
+ export type Identity = { user: { email?: string; hostedDomain?: string } };
119
+
120
+ export type DashboardConfig = {
121
+ allowedEmailCount: number;
122
+ allowedGoogleDomainCount: number;
123
+ authRequired: boolean;
124
+ authPath: string;
125
+ basePath: string;
126
+ sentryConversationLinks: boolean;
127
+ timeZone: string;
128
+ };
129
+
130
+ export type DashboardData = {
131
+ config: DashboardConfig;
132
+ health: Health;
133
+ me: Identity;
134
+ plugins: Plugin[];
135
+ runtime: Runtime;
136
+ sessions: SessionFeed;
137
+ skills: Skill[];
138
+ };
139
+
140
+ export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all";
141
+
142
+ export type VisualStatus = "active" | "failed" | "hung" | "idle";
143
+
144
+ export type CodeBlock = { code: string; language: BundledLanguage };
145
+
146
+ export type MarkupNode =
147
+ | {
148
+ type: "element";
149
+ attributes: Array<[string, string]>;
150
+ children: MarkupNode[];
151
+ tagName: string;
152
+ }
153
+ | { type: "text"; text: string };
package/src/client.tsx ADDED
@@ -0,0 +1,79 @@
1
+ import { QueryClientProvider } from "@tanstack/react-query";
2
+ import { Component, type ErrorInfo, type ReactNode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { BrowserRouter } from "react-router";
5
+
6
+ import { DashboardShell } from "./client/App";
7
+ import { client } from "./client/api";
8
+
9
+ declare global {
10
+ interface Window {
11
+ __JUNIOR_DASHBOARD_BASE_PATH__?: string;
12
+ __JUNIOR_DASHBOARD_SHOW_ERROR__?: (error: unknown) => void;
13
+ }
14
+ }
15
+
16
+ type ErrorBoundaryState = {
17
+ error: Error | null;
18
+ };
19
+
20
+ class DashboardErrorBoundary extends Component<
21
+ { children: ReactNode },
22
+ ErrorBoundaryState
23
+ > {
24
+ state: ErrorBoundaryState = { error: null };
25
+
26
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
27
+ return { error };
28
+ }
29
+
30
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
31
+ const stack = error.stack ?? errorInfo.componentStack;
32
+ window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error);
33
+ }
34
+
35
+ render() {
36
+ if (this.state.error) {
37
+ return <DashboardErrorPanel error={this.state.error} />;
38
+ }
39
+
40
+ return this.props.children;
41
+ }
42
+ }
43
+
44
+ function DashboardErrorPanel(props: { error: Error }) {
45
+ return (
46
+ <main className="grid min-h-screen place-items-center bg-black p-8 text-white">
47
+ <section className="w-full max-w-5xl border border-rose-400/50 bg-[#0b0b0b] p-5 font-sans">
48
+ <div className="font-mono text-xs uppercase leading-none text-[#888]">
49
+ Dashboard Error
50
+ </div>
51
+ <h1 className="mt-2 text-3xl font-bold leading-tight tracking-normal">
52
+ Junior failed to render
53
+ </h1>
54
+ <p className="my-4 max-w-3xl text-[#b8b8b8]">
55
+ The dashboard hit a client-side exception. The stack trace is shown
56
+ here so the page does not fail blank.
57
+ </p>
58
+ <pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words border border-white/10 bg-black p-4 font-mono text-sm leading-relaxed text-white">
59
+ {props.error.stack ?? props.error.message}
60
+ </pre>
61
+ </section>
62
+ </main>
63
+ );
64
+ }
65
+
66
+ const root = document.getElementById("dashboard-root");
67
+ if (!root) {
68
+ throw new Error("Junior dashboard root element was not found");
69
+ }
70
+
71
+ createRoot(root).render(
72
+ <DashboardErrorBoundary>
73
+ <QueryClientProvider client={client}>
74
+ <BrowserRouter basename={window.__JUNIOR_DASHBOARD_BASE_PATH__ ?? "/"}>
75
+ <DashboardShell />
76
+ </BrowserRouter>
77
+ </QueryClientProvider>
78
+ </DashboardErrorBoundary>,
79
+ );
package/src/config.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { JuniorDashboardOptions } from "./app";
2
+
3
+ export type JuniorDashboardRuntimeConfig = Omit<
4
+ JuniorDashboardOptions,
5
+ "auth" | "reporting"
6
+ >;
7
+
8
+ /** Read dashboard runtime config injected by the Nitro module. */
9
+ export async function resolveDashboardConfig(): Promise<JuniorDashboardRuntimeConfig> {
10
+ try {
11
+ const mod: { dashboard?: JuniorDashboardRuntimeConfig } =
12
+ await import("#junior-dashboard/config");
13
+ return mod.dashboard ?? readEnvConfig();
14
+ } catch (error) {
15
+ if (!isMissingVirtualConfig(error)) {
16
+ throw error;
17
+ }
18
+ return readEnvConfig();
19
+ }
20
+ }
21
+
22
+ function readEnvConfig(): JuniorDashboardRuntimeConfig {
23
+ return {
24
+ authRequired: process.env.JUNIOR_DASHBOARD_AUTH_REQUIRED !== "false",
25
+ allowedGoogleDomains: readListEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"),
26
+ allowedEmails: readListEnv("JUNIOR_DASHBOARD_ALLOWED_EMAILS"),
27
+ trustedOrigins: readListEnv("JUNIOR_DASHBOARD_TRUSTED_ORIGINS"),
28
+ };
29
+ }
30
+
31
+ function readListEnv(name: string): string[] {
32
+ const value = process.env[name];
33
+ if (!value?.trim()) {
34
+ return [];
35
+ }
36
+
37
+ if (value.trim().startsWith("[")) {
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(value);
41
+ } catch (error) {
42
+ throw new Error(`${name} must be a JSON string array`, {
43
+ cause: error,
44
+ });
45
+ }
46
+ if (
47
+ !Array.isArray(parsed) ||
48
+ parsed.some((item) => typeof item !== "string")
49
+ ) {
50
+ throw new Error(`${name} must be a JSON string array`);
51
+ }
52
+ return parsed;
53
+ }
54
+
55
+ return value
56
+ .split(",")
57
+ .map((item) => item.trim())
58
+ .filter(Boolean);
59
+ }
60
+
61
+ function isMissingVirtualConfig(error: unknown): boolean {
62
+ if (!(error instanceof Error)) {
63
+ return false;
64
+ }
65
+ const code = (error as { code?: string }).code;
66
+ return (
67
+ (code === "ERR_PACKAGE_IMPORT_NOT_DEFINED" ||
68
+ code === "ERR_MODULE_NOT_FOUND" ||
69
+ code === "MODULE_NOT_FOUND") &&
70
+ error.message.includes("#junior-dashboard/config")
71
+ );
72
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { defineHandler } from "nitro";
2
+ import { createDashboardApp } from "./app";
3
+ import { resolveDashboardConfig } from "./config";
4
+
5
+ let app: ReturnType<typeof createDashboardApp> | undefined;
6
+ let appPromise: Promise<ReturnType<typeof createDashboardApp>> | undefined;
7
+
8
+ async function resolveApp(): Promise<ReturnType<typeof createDashboardApp>> {
9
+ appPromise ??= resolveDashboardConfig()
10
+ .then((config) => {
11
+ app = createDashboardApp(config);
12
+ return app;
13
+ })
14
+ .catch((error: unknown) => {
15
+ appPromise = undefined;
16
+ throw error;
17
+ });
18
+ return app ?? appPromise;
19
+ }
20
+
21
+ const handler: unknown = defineHandler(async (event) => {
22
+ const dashboardApp = await resolveApp();
23
+ return dashboardApp.fetch(event.req);
24
+ });
25
+
26
+ export default handler;
package/src/nitro.ts ADDED
@@ -0,0 +1,110 @@
1
+ import type { Nitro } from "nitro/types";
2
+
3
+ export interface JuniorDashboardNitroOptions {
4
+ basePath?: string;
5
+ authPath?: string;
6
+ authRequired?: boolean;
7
+ allowedGoogleDomains?: string[];
8
+ allowedEmails?: string[];
9
+ trustedOrigins?: string[];
10
+ sessionMaxAgeSeconds?: number;
11
+ disabled?: boolean;
12
+ }
13
+
14
+ type NitroRouteConfig = NonNullable<Nitro["options"]["routes"]>;
15
+
16
+ function normalizePath(path: string | undefined, fallback: string): string {
17
+ const value = path?.trim() || fallback;
18
+ const withSlash = value.startsWith("/") ? value : `/${value}`;
19
+ return stripTrailingSlashes(withSlash);
20
+ }
21
+
22
+ function stripTrailingSlashes(value: string): string {
23
+ let end = value.length;
24
+ while (end > 1 && value.charCodeAt(end - 1) === 47) {
25
+ end -= 1;
26
+ }
27
+ return end === value.length ? value : value.slice(0, end);
28
+ }
29
+
30
+ function routeEntry(handler: string): { handler: string } {
31
+ return { handler };
32
+ }
33
+
34
+ function virtualHandler(config: Record<string, unknown>): string {
35
+ return `import { defineHandler } from "nitro";
36
+ import { createDashboardApp } from "@sentry/junior-dashboard";
37
+
38
+ let app;
39
+
40
+ export default defineHandler(async (event) => {
41
+ app ??= createDashboardApp(${JSON.stringify(config)});
42
+ return app.fetch(event.req);
43
+ });
44
+ `;
45
+ }
46
+
47
+ function dashboardPageRoutes(
48
+ basePath: string,
49
+ handler: string,
50
+ ): NitroRouteConfig {
51
+ const sessionsPath = basePath === "/" ? "/sessions" : `${basePath}/sessions`;
52
+ const conversationsPath =
53
+ basePath === "/" ? "/conversations" : `${basePath}/conversations`;
54
+
55
+ if (basePath === "/") {
56
+ return {
57
+ "/": routeEntry(handler),
58
+ [conversationsPath]: routeEntry(handler),
59
+ [`${conversationsPath}/**`]: routeEntry(handler),
60
+ [sessionsPath]: routeEntry(handler),
61
+ [`${sessionsPath}/**`]: routeEntry(handler),
62
+ };
63
+ }
64
+
65
+ return {
66
+ [basePath]: routeEntry(handler),
67
+ [`${basePath}/**`]: routeEntry(handler),
68
+ };
69
+ }
70
+
71
+ /** Mount the authenticated Junior dashboard into a Nitro deployment. */
72
+ export function juniorDashboardNitro(options: JuniorDashboardNitroOptions): {
73
+ nitro: { setup(nitro: unknown): void };
74
+ } {
75
+ return {
76
+ nitro: {
77
+ setup(nitro: Nitro) {
78
+ if (options.disabled) {
79
+ return;
80
+ }
81
+
82
+ const basePath = normalizePath(options.basePath, "/");
83
+ const authPath = normalizePath(options.authPath, "/api/auth");
84
+ const handler = "#junior-dashboard/handler";
85
+ const dashboardConfig = {
86
+ ...options,
87
+ basePath,
88
+ authPath,
89
+ disabled: undefined,
90
+ };
91
+
92
+ nitro.options.virtual[handler] = virtualHandler(dashboardConfig);
93
+ nitro.options.virtual["#junior-dashboard/config"] =
94
+ `export const dashboard = ${JSON.stringify(dashboardConfig)};`;
95
+
96
+ const dashboardRoutes: NitroRouteConfig = {
97
+ ...dashboardPageRoutes(basePath, handler),
98
+ "/api/dashboard/**": routeEntry(handler),
99
+ [authPath]: routeEntry(handler),
100
+ [`${authPath}/**`]: routeEntry(handler),
101
+ };
102
+
103
+ nitro.options.routes = {
104
+ ...dashboardRoutes,
105
+ ...(nitro.options.routes ?? {}),
106
+ };
107
+ },
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,13 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --font-sans:
5
+ -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
6
+ --font-mono:
7
+ "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
8
+ "Liberation Mono", monospace;
9
+ }
10
+
11
+ @source "./app.ts";
12
+ @source "./client.tsx";
13
+ @source "./client/**/*.{ts,tsx}";
@@ -0,0 +1,5 @@
1
+ declare module "#junior-dashboard/config" {
2
+ import type { JuniorDashboardRuntimeConfig } from "./config";
3
+
4
+ export const dashboard: JuniorDashboardRuntimeConfig;
5
+ }