@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.
- package/LICENSE +201 -0
- package/dist/app.d.ts +22 -0
- package/dist/app.js +492 -0
- package/dist/auth.d.ts +30 -0
- package/dist/client/App.d.ts +2 -0
- package/dist/client/api.d.ts +10 -0
- package/dist/client/code.d.ts +15 -0
- package/dist/client/components/CommandRail.d.ts +6 -0
- package/dist/client/components/ConversationList.d.ts +7 -0
- package/dist/client/components/ConversationRowStats.d.ts +6 -0
- package/dist/client/components/ConversationStack.d.ts +5 -0
- package/dist/client/components/ConversationSummary.d.ts +5 -0
- package/dist/client/components/EmptyTelemetry.d.ts +5 -0
- package/dist/client/components/FilterTabs.d.ts +6 -0
- package/dist/client/components/JuniorLogo.d.ts +2 -0
- package/dist/client/components/LoadingView.d.ts +4 -0
- package/dist/client/components/Section.d.ts +6 -0
- package/dist/client/components/SectionHeader.d.ts +6 -0
- package/dist/client/components/SectionTitle.d.ts +5 -0
- package/dist/client/components/StatusBadge.d.ts +6 -0
- package/dist/client/components/ToolFrame.d.ts +10 -0
- package/dist/client/components/Transcript.d.ts +5 -0
- package/dist/client/components/TranscriptHeader.d.ts +7 -0
- package/dist/client/components/TranscriptLoading.d.ts +2 -0
- package/dist/client/components/TranscriptText.d.ts +6 -0
- package/dist/client/components/TranscriptToolView.d.ts +9 -0
- package/dist/client/components/TranscriptTurn.d.ts +8 -0
- package/dist/client/components/TurnDurationChart.d.ts +6 -0
- package/dist/client/components/statusStyles.d.ts +3 -0
- package/dist/client/components/transcriptPreview.d.ts +4 -0
- package/dist/client/components/transcriptRenderModel.d.ts +30 -0
- package/dist/client/components/transcriptStyles.d.ts +4 -0
- package/dist/client/format.d.ts +64 -0
- package/dist/client/pages/CommandCenter.d.ts +6 -0
- package/dist/client/pages/ConversationPage.d.ts +5 -0
- package/dist/client/pages/ConversationsPage.d.ts +5 -0
- package/dist/client/styles.d.ts +2 -0
- package/dist/client/types.d.ts +153 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +56012 -0
- package/dist/config.d.ts +4 -0
- package/dist/handler.d.ts +2 -0
- package/dist/handler.js +563 -0
- package/dist/nitro.d.ts +16 -0
- package/dist/nitro.js +80 -0
- package/dist/tailwind.css +2 -0
- package/package.json +60 -0
- package/src/app.ts +421 -0
- package/src/auth.ts +202 -0
- package/src/client/App.tsx +175 -0
- package/src/client/api.ts +99 -0
- package/src/client/code.tsx +139 -0
- package/src/client/components/CommandRail.tsx +74 -0
- package/src/client/components/ConversationList.tsx +94 -0
- package/src/client/components/ConversationRowStats.tsx +21 -0
- package/src/client/components/ConversationStack.tsx +67 -0
- package/src/client/components/ConversationSummary.tsx +40 -0
- package/src/client/components/EmptyTelemetry.tsx +11 -0
- package/src/client/components/FilterTabs.tsx +35 -0
- package/src/client/components/JuniorLogo.tsx +8 -0
- package/src/client/components/LoadingView.tsx +16 -0
- package/src/client/components/Section.tsx +17 -0
- package/src/client/components/SectionHeader.tsx +16 -0
- package/src/client/components/SectionTitle.tsx +10 -0
- package/src/client/components/StatusBadge.tsx +40 -0
- package/src/client/components/ToolFrame.tsx +52 -0
- package/src/client/components/Transcript.tsx +39 -0
- package/src/client/components/TranscriptHeader.tsx +52 -0
- package/src/client/components/TranscriptLoading.tsx +10 -0
- package/src/client/components/TranscriptText.tsx +45 -0
- package/src/client/components/TranscriptToolView.tsx +207 -0
- package/src/client/components/TranscriptTurn.tsx +487 -0
- package/src/client/components/TurnDurationChart.tsx +349 -0
- package/src/client/components/statusStyles.ts +9 -0
- package/src/client/components/transcriptPreview.ts +20 -0
- package/src/client/components/transcriptRenderModel.ts +193 -0
- package/src/client/components/transcriptStyles.ts +11 -0
- package/src/client/format.ts +628 -0
- package/src/client/pages/CommandCenter.tsx +37 -0
- package/src/client/pages/ConversationPage.tsx +145 -0
- package/src/client/pages/ConversationsPage.tsx +59 -0
- package/src/client/styles.ts +6 -0
- package/src/client/types.ts +153 -0
- package/src/client.tsx +79 -0
- package/src/config.ts +72 -0
- package/src/handler.ts +26 -0
- package/src/nitro.ts +110 -0
- package/src/tailwind.css +13 -0
- package/src/virtual-modules.d.ts +5 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8;
|
|
4
|
+
|
|
5
|
+
export interface DashboardUser {
|
|
6
|
+
email?: string | null;
|
|
7
|
+
emailVerified?: boolean;
|
|
8
|
+
hostedDomain?: string | null;
|
|
9
|
+
name?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DashboardSession {
|
|
13
|
+
user: DashboardUser;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DashboardAuthConfig {
|
|
17
|
+
baseURL?: string;
|
|
18
|
+
authPath: string;
|
|
19
|
+
trustedOrigins: string[];
|
|
20
|
+
secret?: string;
|
|
21
|
+
googleClientId?: string;
|
|
22
|
+
googleClientSecret?: string;
|
|
23
|
+
googleHostedDomain?: string;
|
|
24
|
+
sessionMaxAgeSeconds?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DashboardAuth {
|
|
28
|
+
handler(request: Request): Promise<Response>;
|
|
29
|
+
getSession(request: Request): Promise<DashboardSession | null>;
|
|
30
|
+
signInWithGoogle(request: Request, callbackURL: string): Promise<Response>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Keep dashboard identity responses limited to user display fields. */
|
|
34
|
+
export function sanitizeDashboardSession(
|
|
35
|
+
session: DashboardSession,
|
|
36
|
+
): DashboardSession {
|
|
37
|
+
const { email, emailVerified, hostedDomain, name } = session.user;
|
|
38
|
+
return {
|
|
39
|
+
user: {
|
|
40
|
+
email,
|
|
41
|
+
emailVerified,
|
|
42
|
+
hostedDomain,
|
|
43
|
+
name,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function required(value: string | undefined, name: string): string {
|
|
49
|
+
if (!value?.trim()) {
|
|
50
|
+
throw new Error(`${name} is required for Junior dashboard auth`);
|
|
51
|
+
}
|
|
52
|
+
return value.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function firstHostedDomain(domains: string[]): string | undefined {
|
|
56
|
+
return domains.length === 1 ? domains[0] : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withHttps(host: string): string {
|
|
60
|
+
return /^https?:\/\//.test(host) ? host : `https://${host}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stripTrailingSlashes(value: string): string {
|
|
64
|
+
let end = value.length;
|
|
65
|
+
while (end > 1 && value.charCodeAt(end - 1) === 47) {
|
|
66
|
+
end -= 1;
|
|
67
|
+
}
|
|
68
|
+
return end === value.length ? value : value.slice(0, end);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveBaseURL(config: DashboardAuthConfig): string {
|
|
72
|
+
const explicit =
|
|
73
|
+
config.baseURL ??
|
|
74
|
+
process.env.BETTER_AUTH_URL ??
|
|
75
|
+
process.env.JUNIOR_BASE_URL;
|
|
76
|
+
if (explicit?.trim()) {
|
|
77
|
+
return stripTrailingSlashes(withHttps(explicit.trim()));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim();
|
|
81
|
+
if (vercelProd) {
|
|
82
|
+
return stripTrailingSlashes(withHttps(vercelProd));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const vercelUrl = process.env.VERCEL_URL?.trim();
|
|
86
|
+
if (vercelUrl) {
|
|
87
|
+
return stripTrailingSlashes(withHttps(vercelUrl));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return "http://localhost:3000";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Create the Better Auth bridge used by dashboard browser routes. */
|
|
94
|
+
export function createDashboardAuth(
|
|
95
|
+
config: DashboardAuthConfig,
|
|
96
|
+
): DashboardAuth {
|
|
97
|
+
const secret = required(
|
|
98
|
+
config.secret ??
|
|
99
|
+
process.env.BETTER_AUTH_SECRET ??
|
|
100
|
+
process.env.JUNIOR_SECRET,
|
|
101
|
+
"JUNIOR_SECRET or BETTER_AUTH_SECRET",
|
|
102
|
+
);
|
|
103
|
+
const baseURL = resolveBaseURL(config);
|
|
104
|
+
const googleClientId = required(
|
|
105
|
+
config.googleClientId ?? process.env.GOOGLE_CLIENT_ID,
|
|
106
|
+
"GOOGLE_CLIENT_ID",
|
|
107
|
+
);
|
|
108
|
+
const googleClientSecret = required(
|
|
109
|
+
config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET,
|
|
110
|
+
"GOOGLE_CLIENT_SECRET",
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const auth = betterAuth({
|
|
114
|
+
appName: "Junior Dashboard",
|
|
115
|
+
baseURL,
|
|
116
|
+
basePath: config.authPath,
|
|
117
|
+
secret,
|
|
118
|
+
trustedOrigins: config.trustedOrigins,
|
|
119
|
+
socialProviders: {
|
|
120
|
+
google: {
|
|
121
|
+
clientId: googleClientId,
|
|
122
|
+
clientSecret: googleClientSecret,
|
|
123
|
+
hd: config.googleHostedDomain,
|
|
124
|
+
prompt: "select_account",
|
|
125
|
+
mapProfileToUser(profile) {
|
|
126
|
+
return {
|
|
127
|
+
email: profile.email,
|
|
128
|
+
emailVerified: profile.email_verified,
|
|
129
|
+
hostedDomain: profile.hd,
|
|
130
|
+
image: profile.picture,
|
|
131
|
+
name: profile.name,
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
user: {
|
|
137
|
+
additionalFields: {
|
|
138
|
+
hostedDomain: {
|
|
139
|
+
type: "string",
|
|
140
|
+
required: false,
|
|
141
|
+
input: false,
|
|
142
|
+
returned: true,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
account: {
|
|
147
|
+
storeStateStrategy: "cookie",
|
|
148
|
+
storeAccountCookie: false,
|
|
149
|
+
updateAccountOnSignIn: false,
|
|
150
|
+
},
|
|
151
|
+
session: {
|
|
152
|
+
expiresIn: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
|
|
153
|
+
disableSessionRefresh: true,
|
|
154
|
+
cookieCache: {
|
|
155
|
+
enabled: true,
|
|
156
|
+
strategy: "jwe",
|
|
157
|
+
maxAge: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS,
|
|
158
|
+
refreshCache: false,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
handler(request) {
|
|
165
|
+
return auth.handler(request);
|
|
166
|
+
},
|
|
167
|
+
async getSession(request) {
|
|
168
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
169
|
+
if (!session) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return sanitizeDashboardSession(session as DashboardSession);
|
|
173
|
+
},
|
|
174
|
+
async signInWithGoogle(request, callbackURL) {
|
|
175
|
+
const result = await auth.api.signInSocial({
|
|
176
|
+
body: {
|
|
177
|
+
provider: "google",
|
|
178
|
+
callbackURL,
|
|
179
|
+
},
|
|
180
|
+
headers: request.headers,
|
|
181
|
+
returnHeaders: true,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (!("url" in result.response) || !result.response.url) {
|
|
185
|
+
throw new Error("Google sign-in did not return a redirect URL");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
result.headers.set("location", result.response.url);
|
|
189
|
+
return new Response(null, {
|
|
190
|
+
status: 302,
|
|
191
|
+
headers: result.headers,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Resolve a Google hosted-domain login hint when it is unambiguous. */
|
|
198
|
+
export function resolveGoogleHostedDomainHint(
|
|
199
|
+
domains: string[],
|
|
200
|
+
): string | undefined {
|
|
201
|
+
return firstHostedDomain(domains.map((domain) => domain.toLowerCase()));
|
|
202
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Link,
|
|
3
|
+
Navigate,
|
|
4
|
+
NavLink,
|
|
5
|
+
Route,
|
|
6
|
+
Routes,
|
|
7
|
+
useParams,
|
|
8
|
+
} from "react-router";
|
|
9
|
+
|
|
10
|
+
import { useDashboardData } from "./api";
|
|
11
|
+
import { LoadingView } from "./components/LoadingView";
|
|
12
|
+
import {
|
|
13
|
+
conversationPath,
|
|
14
|
+
setDashboardTimeZone,
|
|
15
|
+
visualStatusForSession,
|
|
16
|
+
} from "./format";
|
|
17
|
+
import { CommandCenter } from "./pages/CommandCenter";
|
|
18
|
+
import { ConversationPage } from "./pages/ConversationPage";
|
|
19
|
+
import { ConversationsPage } from "./pages/ConversationsPage";
|
|
20
|
+
import { cn } from "./styles";
|
|
21
|
+
|
|
22
|
+
/** Render the dashboard SPA shell and route-level loading states. */
|
|
23
|
+
export function DashboardShell() {
|
|
24
|
+
const query = useDashboardData();
|
|
25
|
+
const data = query.data;
|
|
26
|
+
if (data) {
|
|
27
|
+
setDashboardTimeZone(data.config.timeZone);
|
|
28
|
+
}
|
|
29
|
+
const loading = !data && !query.error;
|
|
30
|
+
const loggedIn = Boolean(data?.config.authRequired && data.me.user.email);
|
|
31
|
+
const activeTurnCount =
|
|
32
|
+
data?.sessions.sessions.filter(
|
|
33
|
+
(session) => visualStatusForSession(session) === "active",
|
|
34
|
+
).length ?? 0;
|
|
35
|
+
const headerSummary = query.error
|
|
36
|
+
? query.error.message
|
|
37
|
+
: data
|
|
38
|
+
? `${data.plugins.length} plugins / ${data.skills.length} skills / ${activeTurnCount} active`
|
|
39
|
+
: "loading command center";
|
|
40
|
+
|
|
41
|
+
async function signOut() {
|
|
42
|
+
await fetch(`${data?.config.authPath ?? "/api/auth"}/sign-out`, {
|
|
43
|
+
credentials: "same-origin",
|
|
44
|
+
method: "POST",
|
|
45
|
+
});
|
|
46
|
+
window.location.assign(data?.config.basePath ?? "/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
|
50
|
+
cn(
|
|
51
|
+
"whitespace-nowrap border-b-4 px-0.5 pb-1.5 pt-2 text-[0.9rem] font-semibold leading-tight no-underline transition-colors",
|
|
52
|
+
isActive
|
|
53
|
+
? "border-b-[#beaaff] text-white"
|
|
54
|
+
: "border-b-transparent text-[#b8b8b8] hover:border-b-white/45 hover:text-white",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<main className="grid min-h-screen grid-rows-[auto_1fr] bg-black font-sans text-white">
|
|
59
|
+
<header className="sticky top-0 z-10 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4 border-b border-white/10 bg-[#050505]/95 px-4 py-3 backdrop-blur md:px-8 max-md:grid-cols-1">
|
|
60
|
+
<Link
|
|
61
|
+
className="flex min-w-0 max-w-full justify-self-start text-inherit no-underline"
|
|
62
|
+
to="/"
|
|
63
|
+
>
|
|
64
|
+
<div className="min-w-0">
|
|
65
|
+
<h1 className="m-0 text-2xl font-bold leading-none tracking-normal">
|
|
66
|
+
Junior
|
|
67
|
+
</h1>
|
|
68
|
+
<div className="mt-1 truncate text-[0.82rem] leading-tight text-[#888]">
|
|
69
|
+
{headerSummary}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</Link>
|
|
73
|
+
<div className="flex min-w-0 items-center gap-2 max-md:flex-wrap max-md:justify-between">
|
|
74
|
+
<nav className="flex min-w-0 items-center gap-5">
|
|
75
|
+
<NavLink className={navLinkClass} end to="/">
|
|
76
|
+
Command
|
|
77
|
+
</NavLink>
|
|
78
|
+
<NavLink className={navLinkClass} to="/conversations">
|
|
79
|
+
Conversations
|
|
80
|
+
</NavLink>
|
|
81
|
+
</nav>
|
|
82
|
+
{loggedIn ? (
|
|
83
|
+
<button
|
|
84
|
+
aria-label="Log out"
|
|
85
|
+
className="grid size-9 cursor-pointer place-items-center border border-white/15 bg-[#0b0b0b] p-0 text-[#b8b8b8] transition-colors hover:border-white/30 hover:bg-[#151515] hover:text-white"
|
|
86
|
+
type="button"
|
|
87
|
+
title="Log out"
|
|
88
|
+
onClick={() => void signOut()}
|
|
89
|
+
>
|
|
90
|
+
<svg
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
fill="none"
|
|
93
|
+
height="16"
|
|
94
|
+
viewBox="0 0 24 24"
|
|
95
|
+
width="16"
|
|
96
|
+
>
|
|
97
|
+
<path
|
|
98
|
+
d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"
|
|
99
|
+
stroke="currentColor"
|
|
100
|
+
strokeLinecap="round"
|
|
101
|
+
strokeLinejoin="round"
|
|
102
|
+
strokeWidth="2"
|
|
103
|
+
/>
|
|
104
|
+
<path
|
|
105
|
+
d="m16 17 5-5-5-5"
|
|
106
|
+
stroke="currentColor"
|
|
107
|
+
strokeLinecap="round"
|
|
108
|
+
strokeLinejoin="round"
|
|
109
|
+
strokeWidth="2"
|
|
110
|
+
/>
|
|
111
|
+
<path
|
|
112
|
+
d="M21 12H9"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeLinecap="round"
|
|
115
|
+
strokeLinejoin="round"
|
|
116
|
+
strokeWidth="2"
|
|
117
|
+
/>
|
|
118
|
+
</svg>
|
|
119
|
+
</button>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
</header>
|
|
123
|
+
|
|
124
|
+
<Routes>
|
|
125
|
+
<Route
|
|
126
|
+
element={
|
|
127
|
+
loading ? (
|
|
128
|
+
<LoadingView label="Loading command center" />
|
|
129
|
+
) : (
|
|
130
|
+
<CommandCenter data={data} queryError={query.error} />
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
path="/"
|
|
134
|
+
/>
|
|
135
|
+
<Route
|
|
136
|
+
element={
|
|
137
|
+
loading ? (
|
|
138
|
+
<LoadingView label="Loading conversations" />
|
|
139
|
+
) : (
|
|
140
|
+
<ConversationsPage data={data} />
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
path="/conversations"
|
|
144
|
+
/>
|
|
145
|
+
<Route
|
|
146
|
+
element={
|
|
147
|
+
loading ? (
|
|
148
|
+
<LoadingView label="Loading conversation" />
|
|
149
|
+
) : (
|
|
150
|
+
<ConversationPage data={data} />
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
path="/conversations/:conversationId"
|
|
154
|
+
/>
|
|
155
|
+
<Route
|
|
156
|
+
element={<Navigate replace to="/conversations" />}
|
|
157
|
+
path="/sessions"
|
|
158
|
+
/>
|
|
159
|
+
<Route
|
|
160
|
+
element={<LegacyConversationRedirect />}
|
|
161
|
+
path="/sessions/:conversationId"
|
|
162
|
+
/>
|
|
163
|
+
<Route element={<Navigate replace to="/" />} path="*" />
|
|
164
|
+
</Routes>
|
|
165
|
+
</main>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function LegacyConversationRedirect() {
|
|
170
|
+
const routeParams = useParams();
|
|
171
|
+
const conversationId = routeParams.conversationId
|
|
172
|
+
? decodeURIComponent(routeParams.conversationId)
|
|
173
|
+
: "";
|
|
174
|
+
return <Navigate replace to={conversationPath(conversationId)} />;
|
|
175
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { QueryClient, useQuery } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ConversationDetailFeed,
|
|
5
|
+
DashboardConfig,
|
|
6
|
+
DashboardData,
|
|
7
|
+
Health,
|
|
8
|
+
Identity,
|
|
9
|
+
Plugin,
|
|
10
|
+
Runtime,
|
|
11
|
+
SessionFeed,
|
|
12
|
+
Skill,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
/** Share dashboard query cache between route data and tooltip detail lookups. */
|
|
16
|
+
export const client = new QueryClient();
|
|
17
|
+
|
|
18
|
+
class DashboardApiError extends Error {
|
|
19
|
+
readonly status: number;
|
|
20
|
+
|
|
21
|
+
constructor(path: string, status: number) {
|
|
22
|
+
super(`${path} returned ${status}`);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function restartDashboardSignIn(): void {
|
|
28
|
+
if (typeof window === "undefined") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const loginPath = "/api/dashboard/login";
|
|
33
|
+
if (window.location.pathname !== loginPath) {
|
|
34
|
+
window.location.assign(loginPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function read<T>(path: string): Promise<T> {
|
|
39
|
+
const response = await fetch(path, { credentials: "same-origin" });
|
|
40
|
+
if (response.status === 401) {
|
|
41
|
+
restartDashboardSignIn();
|
|
42
|
+
throw new DashboardApiError(path, response.status);
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) throw new DashboardApiError(path, response.status);
|
|
45
|
+
return (await response.json()) as T;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Poll the dashboard summary feed used by command center and conversation lists. */
|
|
49
|
+
export function useDashboardData() {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: ["dashboard"],
|
|
52
|
+
queryFn: async (): Promise<DashboardData> => {
|
|
53
|
+
const [health, runtime, plugins, skills, sessions, me, config] =
|
|
54
|
+
await Promise.all([
|
|
55
|
+
read<Health>("/api/dashboard/health"),
|
|
56
|
+
read<Runtime>("/api/dashboard/runtime"),
|
|
57
|
+
read<Plugin[]>("/api/dashboard/plugins"),
|
|
58
|
+
read<Skill[]>("/api/dashboard/skills"),
|
|
59
|
+
read<SessionFeed>("/api/dashboard/sessions"),
|
|
60
|
+
read<Identity>("/api/dashboard/me"),
|
|
61
|
+
read<DashboardConfig>("/api/dashboard/config"),
|
|
62
|
+
]);
|
|
63
|
+
return {
|
|
64
|
+
config,
|
|
65
|
+
health,
|
|
66
|
+
runtime,
|
|
67
|
+
plugins,
|
|
68
|
+
skills,
|
|
69
|
+
sessions,
|
|
70
|
+
me,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
refetchInterval: 5_000,
|
|
74
|
+
refetchIntervalInBackground: false,
|
|
75
|
+
retry: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Poll one conversation transcript while preserving route-level disabled state. */
|
|
80
|
+
export function useConversationData(conversationId: string | undefined) {
|
|
81
|
+
return useQuery({
|
|
82
|
+
enabled: Boolean(conversationId),
|
|
83
|
+
queryKey: ["conversation", conversationId],
|
|
84
|
+
queryFn: async (): Promise<ConversationDetailFeed> =>
|
|
85
|
+
readConversationData(conversationId!),
|
|
86
|
+
refetchInterval: 5_000,
|
|
87
|
+
refetchIntervalInBackground: false,
|
|
88
|
+
retry: false,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read one conversation transcript payload for dashboard-local detail views. */
|
|
93
|
+
export function readConversationData(
|
|
94
|
+
conversationId: string,
|
|
95
|
+
): Promise<ConversationDetailFeed> {
|
|
96
|
+
return read<ConversationDetailFeed>(
|
|
97
|
+
`/api/dashboard/conversations/${encodeURIComponent(conversationId)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { codeToHtml, type BundledLanguage } from "shiki/bundle/web";
|
|
3
|
+
|
|
4
|
+
import { canRenderStructuredMarkup, parseMarkupNodes } from "./format";
|
|
5
|
+
import type { CodeBlock, MarkupNode } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Count rendered children so transcripts can decide which markup node expands. */
|
|
8
|
+
export function countStructuredBlockChildren(block: CodeBlock): number {
|
|
9
|
+
if (!canRenderStructuredMarkup(block.language)) return 1;
|
|
10
|
+
const rootCount = parseMarkupNodes(block.code, block.language).length;
|
|
11
|
+
return rootCount > 0 ? rootCount : 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Render structured markup blocks as collapsible nodes instead of flat code. */
|
|
15
|
+
export function StructuredMarkup(props: {
|
|
16
|
+
block: CodeBlock;
|
|
17
|
+
firstChildIndex: number;
|
|
18
|
+
lastChildIndex: number;
|
|
19
|
+
}) {
|
|
20
|
+
const nodes = parseMarkupNodes(props.block.code, props.block.language);
|
|
21
|
+
if (nodes.length === 0) {
|
|
22
|
+
return (
|
|
23
|
+
<HighlightedCode
|
|
24
|
+
code={props.block.code}
|
|
25
|
+
language={props.block.language}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{nodes.map((node, index) => (
|
|
33
|
+
<div
|
|
34
|
+
className="grid min-w-0 gap-0 py-0.5 pl-4 font-mono text-[0.86rem] leading-relaxed text-[#b8b8b8]"
|
|
35
|
+
key={index}
|
|
36
|
+
>
|
|
37
|
+
<MarkupNodeView
|
|
38
|
+
defaultOpen={props.firstChildIndex + index === props.lastChildIndex}
|
|
39
|
+
node={node}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
))}
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function MarkupNodeView(props: { defaultOpen?: boolean; node: MarkupNode }) {
|
|
48
|
+
if (props.node.type === "text") {
|
|
49
|
+
return (
|
|
50
|
+
<div className="min-w-0 whitespace-pre-wrap break-words text-white">
|
|
51
|
+
{props.node.text.trim()}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const children = props.node.children;
|
|
57
|
+
const hasChildren = children.length > 0;
|
|
58
|
+
const attributes = props.node.attributes.map(([name, value]) => (
|
|
59
|
+
<span className="ml-1.5 text-[#b8b8b8]" key={name}>
|
|
60
|
+
{name}=<span className="text-white">"{value}"</span>
|
|
61
|
+
</span>
|
|
62
|
+
));
|
|
63
|
+
|
|
64
|
+
if (!hasChildren) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="-ml-1 flex min-w-0 flex-wrap items-baseline px-1 py-0.5">
|
|
67
|
+
<span className="text-[#888]"><</span>
|
|
68
|
+
<span className="font-bold text-white">{props.node.tagName}</span>
|
|
69
|
+
{attributes}
|
|
70
|
+
<span className="text-[#888]"> /></span>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<details
|
|
77
|
+
className="group min-w-0 break-words"
|
|
78
|
+
open={props.defaultOpen ?? true}
|
|
79
|
+
>
|
|
80
|
+
<summary className="-ml-1 flex w-full max-w-full cursor-pointer list-none flex-wrap items-baseline px-1 py-0.5 transition-colors hover:bg-white/[0.05] hover:text-white [&::-webkit-details-marker]:hidden">
|
|
81
|
+
<span className="mr-1 w-2 text-white group-open:hidden">+</span>
|
|
82
|
+
<span className="mr-1 hidden w-2 text-white group-open:inline">-</span>
|
|
83
|
+
<span className="text-[#888]"><</span>
|
|
84
|
+
<span className="font-bold text-white">{props.node.tagName}</span>
|
|
85
|
+
{attributes}
|
|
86
|
+
<span className="text-[#888]">></span>
|
|
87
|
+
</summary>
|
|
88
|
+
<div className="ml-1 grid gap-0 border-l border-white/10 pl-3">
|
|
89
|
+
{children.map((child, index) => (
|
|
90
|
+
<MarkupNodeView
|
|
91
|
+
defaultOpen={index === children.length - 1}
|
|
92
|
+
key={index}
|
|
93
|
+
node={child}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
<div
|
|
98
|
+
className="-ml-1 flex min-w-0 flex-wrap items-baseline px-1 py-0.5 transition-colors hover:bg-white/[0.05]"
|
|
99
|
+
role="button"
|
|
100
|
+
tabIndex={0}
|
|
101
|
+
>
|
|
102
|
+
<span className="text-[#888]"></</span>
|
|
103
|
+
<span className="font-bold text-white">{props.node.tagName}</span>
|
|
104
|
+
<span className="text-[#888]">></span>
|
|
105
|
+
</div>
|
|
106
|
+
</details>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Render highlighted code while keeping Shiki output responsive in transcripts. */
|
|
111
|
+
export function HighlightedCode(props: {
|
|
112
|
+
code: string;
|
|
113
|
+
language: BundledLanguage;
|
|
114
|
+
}) {
|
|
115
|
+
const highlighted = useQuery({
|
|
116
|
+
queryKey: ["highlight", props.language, props.code],
|
|
117
|
+
queryFn: async () =>
|
|
118
|
+
codeToHtml(props.code, {
|
|
119
|
+
lang: props.language,
|
|
120
|
+
theme: "github-dark",
|
|
121
|
+
}),
|
|
122
|
+
staleTime: Infinity,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!highlighted.data) {
|
|
126
|
+
return (
|
|
127
|
+
<pre className="m-0 min-w-0 whitespace-pre-wrap break-words bg-transparent p-0 font-mono text-[0.86rem] leading-relaxed text-white">
|
|
128
|
+
<code>{props.code}</code>
|
|
129
|
+
</pre>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
className="min-w-0 overflow-visible [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:!m-0 [&_pre]:!overflow-visible [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:font-mono [&_pre]:text-[0.86rem] [&_pre]:leading-relaxed"
|
|
136
|
+
dangerouslySetInnerHTML={{ __html: highlighted.data }}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { formatTime, isFailedSession, visualStatusForSession } from "../format";
|
|
2
|
+
import type { DashboardData } from "../types";
|
|
3
|
+
import { Section } from "./Section";
|
|
4
|
+
import { SectionHeader } from "./SectionHeader";
|
|
5
|
+
import { SectionTitle } from "./SectionTitle";
|
|
6
|
+
import { StatusBadge } from "./StatusBadge";
|
|
7
|
+
|
|
8
|
+
/** Render the command-center summary rail from the dashboard health payload. */
|
|
9
|
+
export function CommandRail(props: {
|
|
10
|
+
data?: DashboardData;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
}) {
|
|
13
|
+
const sessions = props.data?.sessions.sessions ?? [];
|
|
14
|
+
const activeSessions = sessions.filter(
|
|
15
|
+
(session) => visualStatusForSession(session) === "active",
|
|
16
|
+
);
|
|
17
|
+
const hungSessions = sessions.filter(
|
|
18
|
+
(session) => visualStatusForSession(session) === "hung",
|
|
19
|
+
);
|
|
20
|
+
const failedSessions = sessions.filter(isFailedSession);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<aside className="min-w-0">
|
|
24
|
+
<Section>
|
|
25
|
+
<SectionHeader
|
|
26
|
+
actions={
|
|
27
|
+
<StatusBadge
|
|
28
|
+
label={
|
|
29
|
+
props.error ? "degraded" : props.data ? "online" : "checking"
|
|
30
|
+
}
|
|
31
|
+
status={props.error ? "failed" : props.data ? "active" : "idle"}
|
|
32
|
+
/>
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
<SectionTitle>Pulse</SectionTitle>
|
|
36
|
+
</SectionHeader>
|
|
37
|
+
<div className="px-4 py-4">
|
|
38
|
+
<div className="text-5xl font-black leading-none text-white md:text-6xl">
|
|
39
|
+
{props.error
|
|
40
|
+
? "ERR"
|
|
41
|
+
: (props.data?.health.status.toUpperCase() ?? "...")}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="mt-3 break-words text-[0.88rem] leading-relaxed text-[#b8b8b8]">
|
|
44
|
+
{props.error
|
|
45
|
+
? props.error.message
|
|
46
|
+
: props.data
|
|
47
|
+
? `${props.data.health.service} / ${formatTime(props.data.health.timestamp)}`
|
|
48
|
+
: "Waiting for Junior telemetry."}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex flex-wrap border-t border-white/10">
|
|
52
|
+
<Stat label="plugins" value={props.data?.plugins.length ?? 0} />
|
|
53
|
+
<Stat label="skills" value={props.data?.skills.length ?? 0} />
|
|
54
|
+
<Stat label="active" value={activeSessions.length} />
|
|
55
|
+
<Stat label="hung" value={hungSessions.length} />
|
|
56
|
+
<Stat label="failed" value={failedSessions.length} />
|
|
57
|
+
</div>
|
|
58
|
+
</Section>
|
|
59
|
+
</aside>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function Stat(props: { label: string; value: number }) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="min-w-0 flex-1 basis-1/2 border-r border-t border-white/10 bg-[#050505] px-3 py-3 first:border-t-0 sm:basis-1/3">
|
|
66
|
+
<div className="text-2xl font-extrabold leading-none text-white">
|
|
67
|
+
{props.value}
|
|
68
|
+
</div>
|
|
69
|
+
<div className="mt-1 text-[0.78rem] font-semibold uppercase leading-tight text-[#888]">
|
|
70
|
+
{props.label}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|