@datalayer/core 1.2.10 → 1.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/lib/api/iam/authentication.js +6 -4
- package/lib/api/iam/healthz.js +2 -2
- package/lib/api/iam/oauth2.js +18 -12
- package/lib/api/iam/secrets.js +1 -2
- package/lib/api/mcp/observability.d.ts +9 -2
- package/lib/api/mcp/observability.js +40 -15
- package/lib/client/auth/strategies.js +1 -1
- package/lib/client/mixins/IAMMixin.js +2 -2
- package/lib/components/checkout/StripeCheckout.d.ts +1 -1
- package/lib/components/checkout/StripeCheckout.js +1 -1
- package/lib/components/display/VisuallyHidden.d.ts +1 -2
- package/lib/components/sharing/sandboxSharing.js +7 -1
- package/lib/config/Configuration.d.ts +4 -4
- package/lib/config/Configuration.js +7 -7
- package/lib/config/index.d.ts +1 -0
- package/lib/config/index.js +1 -0
- package/lib/config/planes.d.ts +45 -0
- package/lib/config/planes.js +53 -0
- package/lib/hooks/useBackdrop.d.ts +4 -5
- package/lib/hooks/useCache.d.ts +52 -19
- package/lib/hooks/useCache.js +310 -198
- package/lib/hooks/useContents.js +3 -0
- package/lib/hooks/useMcp.js +1 -1
- package/lib/hooks/useNavigate.js +10 -0
- package/lib/hooks/useWindowSize.js +1 -1
- package/lib/models/IAMProvidersSpecs.d.ts +10 -5
- package/lib/models/IAMProvidersSpecs.js +0 -23
- package/lib/models/URN.d.ts +37 -0
- package/lib/models/URN.js +59 -1
- package/lib/navigation/adapters/react-router.d.ts +1 -1
- package/lib/navigation/adapters/react-router.js +3 -0
- package/lib/state/sessionEnd.js +1 -1
- package/lib/state/substates/CoreState.js +3 -16
- package/lib/state/substates/IAMState.d.ts +3 -3
- package/lib/state/substates/NavigationState.d.ts +92 -0
- package/lib/state/substates/NavigationState.js +304 -0
- package/lib/state/substates/index.d.ts +1 -0
- package/lib/state/substates/index.js +1 -0
- package/lib/utils/Handles.js +1 -1
- package/lib/utils/Screencapture.d.ts +15 -0
- package/lib/utils/Screencapture.js +26 -23
- package/lib/views/mcp/McpDashboard.js +15 -10
- package/lib/views/mcp/McpHome.js +13 -6
- package/lib/views/mcp/types.d.ts +4 -0
- package/package.json +9 -7
- package/scripts/generate-mcp-types.py +15 -9
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Where the reader is, where they were, and where they left each shell.
|
|
7
|
+
*
|
|
8
|
+
* One store for navigation, in core, so every surface reads and writes the
|
|
9
|
+
* same thing. It answers five questions that used to be four stores in the
|
|
10
|
+
* landing and a handful of ad-hoc effects:
|
|
11
|
+
*
|
|
12
|
+
* - **the shell** — the platform Home, Agentify, or Admin;
|
|
13
|
+
* - **the page each shell was left on**, so switching shells returns the
|
|
14
|
+
* reader to their work rather than to a landing page;
|
|
15
|
+
* {@link useNavigate} records it on every navigation;
|
|
16
|
+
* - **the tab** last chosen in each tabbed view;
|
|
17
|
+
* - **the route asked for** before signing in, to resume afterwards;
|
|
18
|
+
* - **the address before the documentation**, for its way back.
|
|
19
|
+
*
|
|
20
|
+
* **Where each is kept.** The shell rides in a cookie, so it is sent with
|
|
21
|
+
* every request and a server render can read it. The shells' last pages and
|
|
22
|
+
* the tabs are in `localStorage`, which a cookie should not carry. The
|
|
23
|
+
* requested route and the documentation's origin are memory only, on
|
|
24
|
+
* purpose: a reload must forget the route asked for before signing in.
|
|
25
|
+
*
|
|
26
|
+
* Every read and write goes through the storage in a `try`: a browser that
|
|
27
|
+
* refuses its storage forgets, and never throws into a render.
|
|
28
|
+
*
|
|
29
|
+
* @module state/substates/NavigationState
|
|
30
|
+
*/
|
|
31
|
+
import { useStore } from 'zustand';
|
|
32
|
+
import { createStore } from 'zustand/vanilla';
|
|
33
|
+
import { registerSessionState } from '../sessionEnd.js';
|
|
34
|
+
/** The shells, and where each one opens when it has not been visited. */
|
|
35
|
+
export const NAVIGATION_VIEW_HOMES = {
|
|
36
|
+
studio: '/studio',
|
|
37
|
+
// Not `/`: the root is the Studio's, so the Power shell has an address of
|
|
38
|
+
// its own to be sent to.
|
|
39
|
+
home: '/power',
|
|
40
|
+
agentify: '/agentify',
|
|
41
|
+
admin: '/admin',
|
|
42
|
+
};
|
|
43
|
+
/** Well-known view identifiers for {@link NavigationState.tabsByView}. */
|
|
44
|
+
export const LIBRARY_VIEW = 'library';
|
|
45
|
+
export const AGENTS_VIEW = 'agents';
|
|
46
|
+
export const SETTINGS_VIEW = 'settings';
|
|
47
|
+
export const DATALAYER_VIEW = 'datalayer';
|
|
48
|
+
export const CONTENTS_VIEW = 'contents';
|
|
49
|
+
export const TUTOR_VIEW = 'tutor';
|
|
50
|
+
/** Where this store is kept, and what the landing kept it under before. */
|
|
51
|
+
const STORAGE_KEY = 'datalayer-navigation';
|
|
52
|
+
const SHELL_COOKIE = 'datalayer-signed-in-view';
|
|
53
|
+
const FORMER_TABS_KEY = 'datalayer_view_tabs';
|
|
54
|
+
/** Days a remembered shell lives in the cookie. */
|
|
55
|
+
const COOKIE_MAX_AGE_DAYS = 365;
|
|
56
|
+
const isView = (value) => value === 'studio' ||
|
|
57
|
+
value === 'home' ||
|
|
58
|
+
value === 'agentify' ||
|
|
59
|
+
value === 'admin';
|
|
60
|
+
function readCookie(name) {
|
|
61
|
+
if (typeof document === 'undefined') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const match = document.cookie
|
|
65
|
+
.split('; ')
|
|
66
|
+
.find(row => row.startsWith(`${name}=`));
|
|
67
|
+
return match ? decodeURIComponent(match.slice(name.length + 1)) : undefined;
|
|
68
|
+
}
|
|
69
|
+
function writeCookie(name, value) {
|
|
70
|
+
if (typeof document === 'undefined') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const maxAge = COOKIE_MAX_AGE_DAYS * 24 * 60 * 60;
|
|
74
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`;
|
|
75
|
+
}
|
|
76
|
+
function readLocal(name) {
|
|
77
|
+
try {
|
|
78
|
+
return globalThis.localStorage?.getItem(name) ?? null;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What the last visit left, from the two places it belongs in.
|
|
86
|
+
*
|
|
87
|
+
* The shell is taken from the cookie it has always used, so a reader who
|
|
88
|
+
* chose Agentify before this store existed still finds it. The tabs kept
|
|
89
|
+
* under the landing's former key are picked up once.
|
|
90
|
+
*/
|
|
91
|
+
function restore() {
|
|
92
|
+
let persisted = {};
|
|
93
|
+
const stored = readLocal(STORAGE_KEY);
|
|
94
|
+
if (stored) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(stored);
|
|
97
|
+
// The landing kept this under the same key through zustand's
|
|
98
|
+
// `persist`, which wraps what it writes in `{ state, version }`.
|
|
99
|
+
const kept = parsed?.state ?? parsed;
|
|
100
|
+
persisted = kept && typeof kept === 'object' ? kept : {};
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
persisted = {};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const former = readLocal(FORMER_TABS_KEY);
|
|
108
|
+
if (former) {
|
|
109
|
+
try {
|
|
110
|
+
const { state } = JSON.parse(former);
|
|
111
|
+
persisted = { tabsByView: state?.tabsByView ?? {} };
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
persisted = {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const shell = readCookie(SHELL_COOKIE);
|
|
119
|
+
if (isView(shell)) {
|
|
120
|
+
persisted.view = shell;
|
|
121
|
+
}
|
|
122
|
+
persisted.lastRouteByView = restoredRoutes(persisted.lastRouteByView);
|
|
123
|
+
return persisted;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The shells' pages as they were written down, less the ones no shell
|
|
127
|
+
* returns to.
|
|
128
|
+
*
|
|
129
|
+
* What is in storage was written by whichever version ran last, under that
|
|
130
|
+
* version's rules. Before the Power shell had an address of its own, its
|
|
131
|
+
* page could be the root — which is now the Studio's, and would send Power
|
|
132
|
+
* to the Studio — and an earlier version filed pages under the shell the
|
|
133
|
+
* toggle named. Such a page is dropped here rather than carried, so the next
|
|
134
|
+
* write does not put it back.
|
|
135
|
+
*/
|
|
136
|
+
function restoredRoutes(stored) {
|
|
137
|
+
const routes = {};
|
|
138
|
+
if (!stored || typeof stored !== 'object') {
|
|
139
|
+
return routes;
|
|
140
|
+
}
|
|
141
|
+
for (const [view, route] of Object.entries(stored)) {
|
|
142
|
+
if (isView(view) && isRouteOfView(route, view)) {
|
|
143
|
+
routes[view] = route;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return routes;
|
|
147
|
+
}
|
|
148
|
+
function persist(state) {
|
|
149
|
+
const written = {
|
|
150
|
+
view: state.view,
|
|
151
|
+
lastRouteByView: state.lastRouteByView,
|
|
152
|
+
tabsByView: state.tabsByView,
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(written));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* the shells' pages are not remembered */
|
|
159
|
+
}
|
|
160
|
+
writeCookie(SHELL_COOKIE, state.view);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether a page is worth remembering as a shell's.
|
|
164
|
+
*
|
|
165
|
+
* Not the sign-in pages, the OAuth callbacks or the documentation: coming
|
|
166
|
+
* back to a shell should land on the work, not on the door the reader came
|
|
167
|
+
* through or on a page that belongs to no shell.
|
|
168
|
+
*/
|
|
169
|
+
export function isRememberableRoute(route) {
|
|
170
|
+
if (!route.startsWith('/')) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const path = route.split('?')[0];
|
|
174
|
+
return ![
|
|
175
|
+
'/signin',
|
|
176
|
+
'/signup',
|
|
177
|
+
'/login',
|
|
178
|
+
'/logout',
|
|
179
|
+
'/oauth2',
|
|
180
|
+
'/oauth',
|
|
181
|
+
'/docs',
|
|
182
|
+
'/join',
|
|
183
|
+
].some(prefix => path === prefix || path.startsWith(`${prefix}/`));
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The shell a route belongs to, read off its first segment.
|
|
187
|
+
*
|
|
188
|
+
* The store used to trust whatever `view` said when a route was recorded,
|
|
189
|
+
* and `view` was only ever written by the header toggle. A reader who
|
|
190
|
+
* reached Home through a sidebar link while the toggle still said Agentify
|
|
191
|
+
* had their Home pages filed under Agentify — and the toggle, seeing
|
|
192
|
+
* "Agentify" already chosen, did nothing when they clicked it.
|
|
193
|
+
*/
|
|
194
|
+
export function viewForRoute(route) {
|
|
195
|
+
const path = route.split('?')[0];
|
|
196
|
+
// The root is the Studio: what a signed-in reader opens on. The settings
|
|
197
|
+
// are read from it too, with its sidebar beside them.
|
|
198
|
+
if (path === '/' ||
|
|
199
|
+
path === '/studio' ||
|
|
200
|
+
path.startsWith('/studio/') ||
|
|
201
|
+
path === '/settings' ||
|
|
202
|
+
path.startsWith('/settings/')) {
|
|
203
|
+
return 'studio';
|
|
204
|
+
}
|
|
205
|
+
if (path === '/agentify' || path.startsWith('/agentify/')) {
|
|
206
|
+
return 'agentify';
|
|
207
|
+
}
|
|
208
|
+
if (path === '/admin' || path.startsWith('/admin/')) {
|
|
209
|
+
return 'admin';
|
|
210
|
+
}
|
|
211
|
+
return 'home';
|
|
212
|
+
}
|
|
213
|
+
/** Whether a page may be the one a shell returns to. */
|
|
214
|
+
function isRouteOfView(route, view) {
|
|
215
|
+
return (typeof route === 'string' &&
|
|
216
|
+
isRememberableRoute(route) &&
|
|
217
|
+
viewForRoute(route) === view);
|
|
218
|
+
}
|
|
219
|
+
const initial = restore();
|
|
220
|
+
export const navigationStore = createStore((set, get) => ({
|
|
221
|
+
// The Studio, for a reader who never chose: a cookie from before it
|
|
222
|
+
// existed says `home`, and is honoured.
|
|
223
|
+
view: initial.view ?? 'studio',
|
|
224
|
+
lastRouteByView: initial.lastRouteByView ?? {},
|
|
225
|
+
tabsByView: initial.tabsByView ?? {},
|
|
226
|
+
requestedRoute: undefined,
|
|
227
|
+
docsOrigin: undefined,
|
|
228
|
+
setView: view => {
|
|
229
|
+
set({ view });
|
|
230
|
+
persist(get());
|
|
231
|
+
},
|
|
232
|
+
toggleView: () => {
|
|
233
|
+
set(state => ({ view: state.view === 'home' ? 'agentify' : 'home' }));
|
|
234
|
+
persist(get());
|
|
235
|
+
},
|
|
236
|
+
rememberRoute: (route, view) => {
|
|
237
|
+
if (!isRememberableRoute(route)) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// The route says which shell it belongs to; the caller may only insist
|
|
241
|
+
// when it knows better. And being on a shell's page *is* being in that
|
|
242
|
+
// shell, so the view follows.
|
|
243
|
+
const shell = view ?? viewForRoute(route);
|
|
244
|
+
// Told which shell, the caller is filing a page away, not going there.
|
|
245
|
+
const follow = view === undefined;
|
|
246
|
+
const current = get();
|
|
247
|
+
if (current.lastRouteByView[shell] === route &&
|
|
248
|
+
(!follow || current.view === shell)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
set(state => ({
|
|
252
|
+
view: follow ? shell : state.view,
|
|
253
|
+
lastRouteByView: { ...state.lastRouteByView, [shell]: route },
|
|
254
|
+
}));
|
|
255
|
+
persist(get());
|
|
256
|
+
},
|
|
257
|
+
routeForView: view => {
|
|
258
|
+
// Only a page of that shell: an earlier version filed pages under the
|
|
259
|
+
// shell the toggle named rather than the one the route belonged to, so a
|
|
260
|
+
// reader can still have `/items` written down as Agentify's page. Going
|
|
261
|
+
// "to Agentify" must never land on a Home page — nor Power on the root,
|
|
262
|
+
// which was its page before it had `/power` and is now the Studio's.
|
|
263
|
+
const remembered = get().lastRouteByView[view];
|
|
264
|
+
return isRouteOfView(remembered, view)
|
|
265
|
+
? remembered
|
|
266
|
+
: NAVIGATION_VIEW_HOMES[view];
|
|
267
|
+
},
|
|
268
|
+
setViewTab: (view, tab) => {
|
|
269
|
+
if (get().tabsByView[view] === tab) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
set(state => ({ tabsByView: { ...state.tabsByView, [view]: tab } }));
|
|
273
|
+
persist(get());
|
|
274
|
+
},
|
|
275
|
+
getViewTab: view => get().tabsByView[view],
|
|
276
|
+
setRequestedRoute: route => set({ requestedRoute: route }),
|
|
277
|
+
clearRequestedRoute: () => set({ requestedRoute: undefined }),
|
|
278
|
+
setDocsOrigin: origin => set({ docsOrigin: origin }),
|
|
279
|
+
}));
|
|
280
|
+
// The pages each shell was left on, the tabs, the route asked for and the
|
|
281
|
+
// way back from the documentation belong to the person who was signed in:
|
|
282
|
+
// forgotten when the session ends. The shell itself stays — it is the
|
|
283
|
+
// browser's choice of surface, read by the anonymous landing too.
|
|
284
|
+
registerSessionState({
|
|
285
|
+
forget: () => {
|
|
286
|
+
navigationStore.setState({
|
|
287
|
+
lastRouteByView: {},
|
|
288
|
+
tabsByView: {},
|
|
289
|
+
requestedRoute: undefined,
|
|
290
|
+
docsOrigin: undefined,
|
|
291
|
+
});
|
|
292
|
+
persist(navigationStore.getState());
|
|
293
|
+
},
|
|
294
|
+
localStorageKeys: [FORMER_TABS_KEY],
|
|
295
|
+
});
|
|
296
|
+
function useNavigationState(selector) {
|
|
297
|
+
return useStore(navigationStore, selector ?? ((state) => state));
|
|
298
|
+
}
|
|
299
|
+
export const useNavigationStore = Object.assign(useNavigationState, {
|
|
300
|
+
getState: navigationStore.getState,
|
|
301
|
+
setState: navigationStore.setState,
|
|
302
|
+
subscribe: navigationStore.subscribe,
|
|
303
|
+
});
|
|
304
|
+
export default useNavigationStore;
|
package/lib/utils/Handles.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
-
|
|
5
|
+
import { IAM_PROVIDER_URN_PREFIX as EXT_URN_PREFIX } from '../models/URN.js';
|
|
6
6
|
export function isExternalUrnHandle(handle) {
|
|
7
7
|
const normalized = (handle || '').trim().toLowerCase();
|
|
8
8
|
return normalized.startsWith(EXT_URN_PREFIX);
|
|
@@ -1 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A picture of one rendered node, as a PNG data URL.
|
|
3
|
+
*
|
|
4
|
+
* This used `html2canvas`, which does not capture a node: it clones the
|
|
5
|
+
* **whole document** into a hidden iframe and paints all of it, then crops.
|
|
6
|
+
* On a rendered notebook that is every cell, every editor and every output,
|
|
7
|
+
* and the clone is synchronous — the page froze for ten seconds or more with
|
|
8
|
+
* the share dialog saying "preparing…", which reads as a hang, and on a
|
|
9
|
+
* longer notebook it was one. `html-to-image` serialises the node's own
|
|
10
|
+
* subtree into an SVG `foreignObject` and rasterises that: the work is
|
|
11
|
+
* proportional to the output being shared, and it yields to the page.
|
|
12
|
+
*
|
|
13
|
+
* Cross-origin pictures are fetched for the clone, as before, so a cell
|
|
14
|
+
* carrying an `<img>` from the web does not capture as a blank rectangle.
|
|
15
|
+
*/
|
|
1
16
|
export declare const takeHTMLNodeScreencapture: (node: HTMLDivElement) => Promise<string>;
|
|
@@ -2,29 +2,32 @@
|
|
|
2
2
|
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import { toPng } from 'html-to-image';
|
|
6
|
+
/**
|
|
7
|
+
* A picture of one rendered node, as a PNG data URL.
|
|
8
|
+
*
|
|
9
|
+
* This used `html2canvas`, which does not capture a node: it clones the
|
|
10
|
+
* **whole document** into a hidden iframe and paints all of it, then crops.
|
|
11
|
+
* On a rendered notebook that is every cell, every editor and every output,
|
|
12
|
+
* and the clone is synchronous — the page froze for ten seconds or more with
|
|
13
|
+
* the share dialog saying "preparing…", which reads as a hang, and on a
|
|
14
|
+
* longer notebook it was one. `html-to-image` serialises the node's own
|
|
15
|
+
* subtree into an SVG `foreignObject` and rasterises that: the work is
|
|
16
|
+
* proportional to the output being shared, and it yields to the page.
|
|
17
|
+
*
|
|
18
|
+
* Cross-origin pictures are fetched for the clone, as before, so a cell
|
|
19
|
+
* carrying an `<img>` from the web does not capture as a blank rectangle.
|
|
20
|
+
*/
|
|
6
21
|
export const takeHTMLNodeScreencapture = async (node) => {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const top = 0;
|
|
18
|
-
const left = 0;
|
|
19
|
-
const width = canvas.width;
|
|
20
|
-
const height = canvas.height;
|
|
21
|
-
croppedCanvas.width = width;
|
|
22
|
-
croppedCanvas.height = height;
|
|
23
|
-
croppedCanvasContext?.drawImage(canvas, left, top, width, height, left, top, width, height);
|
|
24
|
-
// const type = 'image/jpeg';
|
|
25
|
-
const type = 'image/png';
|
|
26
|
-
const quality = 1;
|
|
27
|
-
const outputshotData = croppedCanvas.toDataURL(type, quality);
|
|
28
|
-
return outputshotData;
|
|
22
|
+
const { backgroundColor } = getComputedStyle(node);
|
|
23
|
+
return toPng(node, {
|
|
24
|
+
// Crisp on a dense display, still a modest file for a text output.
|
|
25
|
+
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
|
|
26
|
+
// Outputs are usually transparent over the page's canvas: give the
|
|
27
|
+
// picture that canvas rather than a transparent one.
|
|
28
|
+
backgroundColor: backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)'
|
|
29
|
+
? backgroundColor
|
|
30
|
+
: getComputedStyle(document.body).backgroundColor || '#ffffff',
|
|
31
|
+
cacheBust: true,
|
|
29
32
|
});
|
|
30
33
|
};
|
|
@@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { ActionList, ActionMenu, Button, Heading, Label, Link, RelativeTime, Select, Spinner, Text, } from '@primer/react';
|
|
4
4
|
import { Blankslate, DataTable, Dialog, Table, } from '@primer/react/experimental';
|
|
5
5
|
import { Box } from '@datalayer/primer-addons';
|
|
6
|
-
import { ChecklistIcon, KebabHorizontalIcon, PlayIcon, PlugIcon, StackIcon,
|
|
6
|
+
import { ChecklistIcon, KebabHorizontalIcon, PlayIcon, PlugIcon, StackIcon, } from '@primer/octicons-react';
|
|
7
7
|
import { ShareAccessDialog, sandboxSharingUrl } from '../../components/sharing/index.js';
|
|
8
8
|
import { ClientBadge, McpErrorBlankslate } from '../../components/mcp/index.js';
|
|
9
9
|
import { useDisconnectAgent, useMcpActivity, useTerminateBinding, } from '../../hooks/useMcp.js';
|
|
@@ -54,7 +54,7 @@ const boundTo = (client) => {
|
|
|
54
54
|
return '';
|
|
55
55
|
};
|
|
56
56
|
const countLook = (tone) => tone === 'danger' ? 'danger.fg' : 'fg.default';
|
|
57
|
-
const Count = ({ label, value, tone = 'neutral', note, }) => (_jsxs(Box, { sx: {
|
|
57
|
+
const Count = ({ label, value, tone = 'neutral', note, to, onOpen, }) => (_jsxs(Box, { sx: {
|
|
58
58
|
p: 3,
|
|
59
59
|
border: '1px solid',
|
|
60
60
|
borderColor: 'border.default',
|
|
@@ -66,7 +66,14 @@ const Count = ({ label, value, tone = 'neutral', note, }) => (_jsxs(Box, { sx: {
|
|
|
66
66
|
fontSize: 4,
|
|
67
67
|
fontWeight: 'bold',
|
|
68
68
|
color: countLook(tone),
|
|
69
|
-
}, children: value }),
|
|
69
|
+
}, children: value }), to ? (_jsx(Link, { href: to, onClick: (event) => {
|
|
70
|
+
// A modified click asks for a new tab; `href` is real, so let it.
|
|
71
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
onOpen?.(to);
|
|
76
|
+
}, sx: { display: 'block', fontSize: 0 }, children: label })) : (_jsx(Text, { sx: { display: 'block', fontSize: 0, color: 'fg.muted' }, children: label })), note && (_jsx(Text, { sx: { display: 'block', fontSize: 0, color: 'fg.subtle', mt: 1 }, children: note }))] }));
|
|
70
77
|
/** A panel that has nothing to show, and the step that would fill it. */
|
|
71
78
|
const PanelEmpty = ({ icon: Icon, heading, description, action, }) => (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(Icon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: heading }), _jsx(Blankslate.Description, { children: _jsx(Text, { as: "p", sx: { textAlign: 'center', m: 0 }, children: description }) }), action && (_jsx(Button, { size: "small", onClick: action.onClick, children: action.label }))] }));
|
|
72
79
|
export const McpDashboard = ({ errorState, routes, org, showTitle = true, section, }) => {
|
|
@@ -379,7 +386,10 @@ export const McpDashboard = ({ errorState, routes, org, showTitle = true, sectio
|
|
|
379
386
|
'repeat(3, 1fr)',
|
|
380
387
|
'repeat(6, 1fr)',
|
|
381
388
|
],
|
|
382
|
-
}, children: [_jsx(Count, { label: "Clients connected", value: clients.length }), _jsx(Count, { label: "Sandboxes bound", value: sandboxes.filter(binding => (binding.state ?? 'active') === 'active').length }), _jsx(Count, { label: "Tasks running", value: data?.tasks.length ?? 0 }), _jsx(Count, { label: "Calls today", value: data?.today.calls ?? 0 }), _jsx(Count, { label: "Refusals today", value: data?.today.refusals ?? 0, tone: (data?.today.refusals ?? 0) > 0 ? 'danger' : 'neutral' }), _jsx(Count, { label: "Credits by agents", value: data?.today.credits ?? 0, note: "today"
|
|
389
|
+
}, children: [_jsx(Count, { label: "Clients connected", value: clients.length, to: routes.agents, onOpen: navigate }), _jsx(Count, { label: "Sandboxes bound", to: routes.sandboxes, onOpen: navigate, value: sandboxes.filter(binding => (binding.state ?? 'active') === 'active').length }), _jsx(Count, { label: "Tasks running", value: data?.tasks.length ?? 0, to: routes.runs, onOpen: navigate }), _jsx(Count, { label: "Calls today", value: data?.today.calls ?? 0, to: routes.calls, onOpen: navigate }), _jsx(Count, { label: "Refusals today", value: data?.today.refusals ?? 0, to: routes.audit, onOpen: navigate, tone: (data?.today.refusals ?? 0) > 0 ? 'danger' : 'neutral' }), _jsx(Count, { label: "Credits by agents", value: data?.today.credits ?? 0, note: "today",
|
|
390
|
+
// Credits are summed from the day's calls, which the Calls tab
|
|
391
|
+
// lists.
|
|
392
|
+
to: routes.calls, onOpen: navigate })] }))), draws('agents') &&
|
|
383
393
|
!loading &&
|
|
384
394
|
(clients.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-clients", children: "Connected clients" }), _jsxs(Table.Subtitle, { as: "p", id: "mcp-clients-subtitle", children: [plural(clients.length, 'client'), " with a live grant on your workspace."] }), _jsx(DataTable, { "aria-labelledby": "mcp-clients", "aria-describedby": "mcp-clients-subtitle", data: clients, columns: clientColumns })] })) : (_jsx(PanelEmpty, { icon: PlugIcon, heading: "No client connected", description: "Connect Claude Code, Codex, Cursor, VS Code or any MCP client to your workspace, and it appears here on its first call.", action: {
|
|
385
395
|
label: 'Set up a client',
|
|
@@ -396,12 +406,7 @@ export const McpDashboard = ({ errorState, routes, org, showTitle = true, sectio
|
|
|
396
406
|
}, children: "No call matches these filters." }))] })) : (_jsx(PanelEmpty, { icon: ChecklistIcon, heading: "No call yet", description: "Ask your agent to list your notebooks. The call, its decision and its outcome land here and in the audit log.", action: {
|
|
397
407
|
label: 'Open the audit log',
|
|
398
408
|
onClick: () => navigate(routes.audit),
|
|
399
|
-
} }))),
|
|
400
|
-
display: 'flex',
|
|
401
|
-
gap: 3,
|
|
402
|
-
alignItems: 'center',
|
|
403
|
-
flexWrap: 'wrap',
|
|
404
|
-
}, children: [_jsx(Button, { size: "small", leadingVisual: TelescopeIcon, onClick: () => navigate(routes.observability), children: "Observability" }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: ["Refreshed ", timeAgo(data?.at) || 'just now', ", and again every ten seconds."] })] })), disconnecting && (_jsx(Dialog, { title: "Disconnect this client?", onClose: () => setDisconnecting(null), returnFocusRef: returnFocusRef, footerButtons: [
|
|
409
|
+
} }))), disconnecting && (_jsx(Dialog, { title: "Disconnect this client?", onClose: () => setDisconnecting(null), returnFocusRef: returnFocusRef, footerButtons: [
|
|
405
410
|
{
|
|
406
411
|
buttonType: 'default',
|
|
407
412
|
content: 'Keep it',
|
package/lib/views/mcp/McpHome.js
CHANGED
|
@@ -63,7 +63,7 @@ export const Copyable = ({ text }) => {
|
|
|
63
63
|
} })] }));
|
|
64
64
|
};
|
|
65
65
|
/** One of the five summaries: a heading, what it holds, and the way in. */
|
|
66
|
-
const Summary = ({ icon: Icon, title, children, action, }) => (_jsxs(Box, { sx: {
|
|
66
|
+
const Summary = ({ icon: Icon, title, children, action, to, onOpen, }) => (_jsxs(Box, { sx: {
|
|
67
67
|
p: 3,
|
|
68
68
|
border: '1px solid',
|
|
69
69
|
borderColor: 'border.default',
|
|
@@ -72,10 +72,17 @@ const Summary = ({ icon: Icon, title, children, action, }) => (_jsxs(Box, { sx:
|
|
|
72
72
|
gap: 2,
|
|
73
73
|
alignContent: 'start',
|
|
74
74
|
minWidth: 0,
|
|
75
|
-
}, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2 }, children: [_jsx(Icon, { size: 16 }),
|
|
75
|
+
}, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 2 }, children: [_jsx(Icon, { size: 16 }), to ? (_jsx(Link, { href: to, onClick: event => {
|
|
76
|
+
// A modified click asks for a new tab; `href` is real, so let it.
|
|
77
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
onOpen?.(to);
|
|
82
|
+
}, sx: { fontSize: 1, fontWeight: 'semibold' }, children: title })) : (_jsx(Text, { sx: { fontSize: 1, fontWeight: 'semibold' }, children: title }))] }), _jsx(Box, { sx: { fontSize: 0, color: 'fg.muted', display: 'grid', gap: 1 }, children: children }), action && (_jsx(Box, { children: _jsx(Link, { as: "button", sx: { fontSize: 0, cursor: 'pointer' }, onClick: action.onClick, children: action.label }) }))] }));
|
|
76
83
|
export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitle = true, showSummaries = true, showSetup = true, onOpenDocs, }) => {
|
|
77
84
|
const navigate = useNavigate();
|
|
78
|
-
const endpoint = useCoreStore(state => state.configuration.
|
|
85
|
+
const endpoint = useCoreStore(state => state.configuration.datalayerMcpServerUrl);
|
|
79
86
|
// Whether there is a token, never what it is: this page shows an address
|
|
80
87
|
// and a status, and a credential belongs on neither.
|
|
81
88
|
const signedIn = Boolean(useIAMStore(state => state.token));
|
|
@@ -130,12 +137,12 @@ export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitl
|
|
|
130
137
|
display: 'grid',
|
|
131
138
|
gap: 3,
|
|
132
139
|
gridTemplateColumns: ['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)'],
|
|
133
|
-
}, children: [_jsx(Summary, { icon: PlugIcon, title: "Agents", action: {
|
|
140
|
+
}, children: [_jsx(Summary, { icon: PlugIcon, title: "Agents", to: routes.agents, onOpen: navigate, action: {
|
|
134
141
|
label: 'Connected agents',
|
|
135
142
|
onClick: () => navigate(routes.agents),
|
|
136
143
|
}, children: connected.length === 0 ? (_jsx(Text, { children: "No agent has been authorized yet. Set up a client above and approve the scopes it asks for." })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [plural(connected.length, 'agent'), " authorized."] }), connected.slice(0, 3).map(agent => (_jsxs(Text, { children: [agent.clientName || agent.clientId, " \u2014", ' ', agent.lastUsedAt
|
|
137
144
|
? `used ${timeAgo(agent.lastUsedAt)}`
|
|
138
|
-
: 'never used'] }, agent.uid)))] })) }), _jsx(Summary, { icon: PulseIcon, title: "Runs", action: { label: 'All runs', onClick: () => navigate(routes.runs) }, children: runs.length === 0 ? (_jsx(Text, { children: "Nothing has run yet. A tool call that outlives its request becomes a run you can watch and cancel." })) : (runs.map(task => (_jsxs(Text, { children: [task.tool, " \u2014 ", task.status, " ", timeAgo(task.lastUpdatedAt)] }, task.uid)))) }), _jsxs(Summary, { icon: TelescopeIcon, title: "Observability", action: {
|
|
145
|
+
: 'never used'] }, agent.uid)))] })) }), _jsx(Summary, { icon: PulseIcon, title: "Runs", to: routes.runs, onOpen: navigate, action: { label: 'All runs', onClick: () => navigate(routes.runs) }, children: runs.length === 0 ? (_jsx(Text, { children: "Nothing has run yet. A tool call that outlives its request becomes a run you can watch and cancel." })) : (runs.map(task => (_jsxs(Text, { children: [task.tool, " \u2014 ", task.status, " ", timeAgo(task.lastUpdatedAt)] }, task.uid)))) }), _jsxs(Summary, { icon: TelescopeIcon, title: "Observability", to: routes.observability, onOpen: navigate, action: {
|
|
139
146
|
label: 'Runs and metrics',
|
|
140
147
|
onClick: () => navigate(routes.observability),
|
|
141
148
|
}, children: [_jsx(Text, { children: metrics.data?.slis.samples.calls
|
|
@@ -145,7 +152,7 @@ export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitl
|
|
|
145
152
|
: 'Latency not measured yet.' }), _jsx(Text, { children: metrics.data?.slis.availability === null ||
|
|
146
153
|
metrics.data?.slis.availability === undefined
|
|
147
154
|
? 'Error rate not measured yet.'
|
|
148
|
-
: `${((1 - metrics.data.slis.availability) * 100).toFixed(1)}% of calls failed.` })] }), _jsx(Summary, { icon: ToolsIcon, title: "Tools", children: _jsx(Text, { children: "The toolsets an agent may execute \u2014 the Contents sources you have enabled for it, their tools, their approval policy and their health \u2014 are managed here once toolset bindings ship." }) }), _jsx(Summary, { icon: ShareIcon, title: "Reach", children: _jsx(Text, { children: "Which notebooks and sources an agent can actually reach, as a list you can read at a glance, arrives with sharing to agents as principals." }) }), _jsx(Summary, { icon: KeyIcon, title: "Policies", action: {
|
|
155
|
+
: `${((1 - metrics.data.slis.availability) * 100).toFixed(1)}% of calls failed.` })] }), _jsx(Summary, { icon: ToolsIcon, title: "Tools", children: _jsx(Text, { children: "The toolsets an agent may execute \u2014 the Contents sources you have enabled for it, their tools, their approval policy and their health \u2014 are managed here once toolset bindings ship." }) }), _jsx(Summary, { icon: ShareIcon, title: "Reach", children: _jsx(Text, { children: "Which notebooks and sources an agent can actually reach, as a list you can read at a glance, arrives with sharing to agents as principals." }) }), _jsx(Summary, { icon: KeyIcon, title: "Policies", to: routes.policies, onOpen: navigate, action: {
|
|
149
156
|
label: 'What applies',
|
|
150
157
|
onClick: () => navigate(routes.policies),
|
|
151
158
|
}, children: _jsx(Text, { children: "What your agents are allowed to do, and which layer decided each rule." }) })] }))] }));
|
package/lib/views/mcp/types.d.ts
CHANGED
|
@@ -63,4 +63,8 @@ export interface McpRoutes {
|
|
|
63
63
|
agents: string;
|
|
64
64
|
/** The effective policy. */
|
|
65
65
|
policies: string;
|
|
66
|
+
/** The calls list, where an Overview count of calls or credits leads. */
|
|
67
|
+
calls?: string;
|
|
68
|
+
/** The bound code sandboxes, where the Overview's sandbox count leads. */
|
|
69
|
+
sandboxes?: string;
|
|
66
70
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datalayer/core",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
".",
|
|
@@ -109,11 +109,11 @@
|
|
|
109
109
|
"rebuild:fresh": "npm run create:patches && npm install && npm run build && npm run clean:cache"
|
|
110
110
|
},
|
|
111
111
|
"dependencies": {
|
|
112
|
-
"@datalayer/design": "^1.0.
|
|
113
|
-
"@datalayer/icons-react": "^1.0.
|
|
114
|
-
"@datalayer/jupyter-lexical": "^
|
|
115
|
-
"@datalayer/jupyter-react": "^2.0.
|
|
116
|
-
"@datalayer/primer-addons": "^1.0.
|
|
112
|
+
"@datalayer/design": "^1.0.3",
|
|
113
|
+
"@datalayer/icons-react": "^1.0.14",
|
|
114
|
+
"@datalayer/jupyter-lexical": "^2.0.18",
|
|
115
|
+
"@datalayer/jupyter-react": "^2.0.18",
|
|
116
|
+
"@datalayer/primer-addons": "^1.0.23",
|
|
117
117
|
"@datalayer/primer-rjsf": "^1.0.3",
|
|
118
118
|
"@fluentui/react": "^8.125.3",
|
|
119
119
|
"@jupyter-widgets/base-manager": "^1.0.12",
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
"diff": "^8.0.2",
|
|
156
156
|
"echarts": "^5.5.0",
|
|
157
157
|
"echarts-for-react": "^3.0.2",
|
|
158
|
-
"
|
|
158
|
+
"html-to-image": "^1.11.13",
|
|
159
159
|
"jwt-decode": "^2.2.0",
|
|
160
160
|
"localforage": "^1.10.0",
|
|
161
161
|
"lucide-react": "^0.542.0",
|
|
@@ -200,6 +200,8 @@
|
|
|
200
200
|
"@storybook/builder-vite": "^9.1.1",
|
|
201
201
|
"@storybook/react-vite": "^9.1.1",
|
|
202
202
|
"@tailwindcss/postcss": "^4.1.16",
|
|
203
|
+
"@testing-library/dom": "^10.4.1",
|
|
204
|
+
"@testing-library/react": "^16.3.3",
|
|
203
205
|
"@types/node": "^20.11.0",
|
|
204
206
|
"@types/react": "19.2.18",
|
|
205
207
|
"@types/react-dom": "19.2.5",
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
# Distributed under the terms of the Modified BSD License.
|
|
7
7
|
|
|
8
8
|
"""
|
|
9
|
-
Generate
|
|
10
|
-
|
|
9
|
+
Generate TypeScript wire types from the Jupyter MCP Server's OpenAPI document.
|
|
10
|
+
|
|
11
|
+
The output is deterministic, on the `generate-contents-types.py` pattern.
|
|
11
12
|
|
|
12
13
|
The gateway keeps no contract file: its FastAPI application *is* the
|
|
13
14
|
contract, and the document is read from it — imported from the sibling
|
|
14
15
|
checkout of the service when there is one, otherwise fetched from a running
|
|
15
|
-
gateway. `
|
|
16
|
+
gateway. `DATALAYER_MCP_OPENAPI` names either a file or an URL and
|
|
16
17
|
wins over both; the deployed gateway publishes its document without
|
|
17
18
|
authentication at ``/api/mcp/v1/openapi.json``.
|
|
18
19
|
|
|
@@ -40,9 +41,9 @@ from typing import Any
|
|
|
40
41
|
from urllib.request import Request, urlopen
|
|
41
42
|
|
|
42
43
|
ROOT = Path(__file__).parents[1]
|
|
43
|
-
DEFAULT_GATEWAY_CHECKOUT = ROOT.parents[2] / "k8s/services/
|
|
44
|
+
DEFAULT_GATEWAY_CHECKOUT = ROOT.parents[2] / "k8s/services/mcp-server"
|
|
44
45
|
DEFAULT_GATEWAY_OPENAPI_URL = "https://r1.datalayer.run/api/mcp/v1/openapi.json"
|
|
45
|
-
GATEWAY_APP = "
|
|
46
|
+
GATEWAY_APP = "datalayer_mcp_server.main:app"
|
|
46
47
|
OUTPUT = ROOT / "src/api/mcp/generated.ts"
|
|
47
48
|
#: The licence header every source file in this repository carries. It is
|
|
48
49
|
#: emitted here because `fix-license-header` adds it to whatever this writes,
|
|
@@ -106,13 +107,14 @@ def camel(name: str) -> str:
|
|
|
106
107
|
|
|
107
108
|
|
|
108
109
|
def _read_from_checkout(checkout: Path) -> dict[str, Any] | None:
|
|
109
|
-
"""
|
|
110
|
+
"""
|
|
111
|
+
The document of the application in a sibling checkout, in a subprocess.
|
|
110
112
|
|
|
111
113
|
A subprocess so the gateway's imports — its dependencies, its logging
|
|
112
114
|
configuration — never leak into this one, and so an environment that
|
|
113
115
|
cannot import it says so cleanly and the fetch takes over.
|
|
114
116
|
"""
|
|
115
|
-
if not (checkout / "
|
|
117
|
+
if not (checkout / "datalayer_mcp_server").is_dir():
|
|
116
118
|
return None
|
|
117
119
|
module, _, attribute = GATEWAY_APP.partition(":")
|
|
118
120
|
code = (
|
|
@@ -137,13 +139,17 @@ def _read_from_checkout(checkout: Path) -> dict[str, Any] | None:
|
|
|
137
139
|
|
|
138
140
|
|
|
139
141
|
def _read_from_url(url: str) -> dict[str, Any]:
|
|
140
|
-
|
|
142
|
+
if not url.startswith(("http://", "https://")):
|
|
143
|
+
raise ValueError(f"Not an HTTP(S) URL: {url}")
|
|
144
|
+
with urlopen( # nosec B310 - the scheme is checked above
|
|
145
|
+
Request(url, headers={"Accept": "application/json"}), timeout=15
|
|
146
|
+
) as response:
|
|
141
147
|
return json.load(response)
|
|
142
148
|
|
|
143
149
|
|
|
144
150
|
def read_openapi() -> dict[str, Any]:
|
|
145
151
|
"""The document, from the override, the sibling checkout or the deployed gateway."""
|
|
146
|
-
override = os.environ.get("
|
|
152
|
+
override = os.environ.get("DATALAYER_MCP_OPENAPI", "").strip()
|
|
147
153
|
if override.startswith(("http://", "https://")):
|
|
148
154
|
return _read_from_url(override)
|
|
149
155
|
if override:
|