@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.
Files changed (47) hide show
  1. package/README.md +5 -5
  2. package/lib/api/iam/authentication.js +6 -4
  3. package/lib/api/iam/healthz.js +2 -2
  4. package/lib/api/iam/oauth2.js +18 -12
  5. package/lib/api/iam/secrets.js +1 -2
  6. package/lib/api/mcp/observability.d.ts +9 -2
  7. package/lib/api/mcp/observability.js +40 -15
  8. package/lib/client/auth/strategies.js +1 -1
  9. package/lib/client/mixins/IAMMixin.js +2 -2
  10. package/lib/components/checkout/StripeCheckout.d.ts +1 -1
  11. package/lib/components/checkout/StripeCheckout.js +1 -1
  12. package/lib/components/display/VisuallyHidden.d.ts +1 -2
  13. package/lib/components/sharing/sandboxSharing.js +7 -1
  14. package/lib/config/Configuration.d.ts +4 -4
  15. package/lib/config/Configuration.js +7 -7
  16. package/lib/config/index.d.ts +1 -0
  17. package/lib/config/index.js +1 -0
  18. package/lib/config/planes.d.ts +45 -0
  19. package/lib/config/planes.js +53 -0
  20. package/lib/hooks/useBackdrop.d.ts +4 -5
  21. package/lib/hooks/useCache.d.ts +52 -19
  22. package/lib/hooks/useCache.js +310 -198
  23. package/lib/hooks/useContents.js +3 -0
  24. package/lib/hooks/useMcp.js +1 -1
  25. package/lib/hooks/useNavigate.js +10 -0
  26. package/lib/hooks/useWindowSize.js +1 -1
  27. package/lib/models/IAMProvidersSpecs.d.ts +10 -5
  28. package/lib/models/IAMProvidersSpecs.js +0 -23
  29. package/lib/models/URN.d.ts +37 -0
  30. package/lib/models/URN.js +59 -1
  31. package/lib/navigation/adapters/react-router.d.ts +1 -1
  32. package/lib/navigation/adapters/react-router.js +3 -0
  33. package/lib/state/sessionEnd.js +1 -1
  34. package/lib/state/substates/CoreState.js +3 -16
  35. package/lib/state/substates/IAMState.d.ts +3 -3
  36. package/lib/state/substates/NavigationState.d.ts +92 -0
  37. package/lib/state/substates/NavigationState.js +304 -0
  38. package/lib/state/substates/index.d.ts +1 -0
  39. package/lib/state/substates/index.js +1 -0
  40. package/lib/utils/Handles.js +1 -1
  41. package/lib/utils/Screencapture.d.ts +15 -0
  42. package/lib/utils/Screencapture.js +26 -23
  43. package/lib/views/mcp/McpDashboard.js +15 -10
  44. package/lib/views/mcp/McpHome.js +13 -6
  45. package/lib/views/mcp/types.d.ts +4 -0
  46. package/package.json +9 -7
  47. 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;
@@ -1,6 +1,7 @@
1
1
  export * from './CoreState';
2
2
  export * from './IAMState';
3
3
  export * from './LayoutState';
4
+ export * from './NavigationState';
4
5
  export * from './ProfileState';
5
6
  export * from './OrganizationState';
6
7
  export * from './SurveysState';
@@ -5,6 +5,7 @@
5
5
  export * from './CoreState.js';
6
6
  export * from './IAMState.js';
7
7
  export * from './LayoutState.js';
8
+ export * from './NavigationState.js';
8
9
  export * from './ProfileState.js';
9
10
  export * from './OrganizationState.js';
10
11
  export * from './SurveysState.js';
@@ -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
- const EXT_URN_PREFIX = 'urn:dla:iam:ext::';
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 html2canvas from 'html2canvas';
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 sc = html2canvas(node, {
8
- width: node.getBoundingClientRect().width,
9
- height: node.getBoundingClientRect().height,
10
- // Cross-origin pictures are otherwise left out and their space kept, so a
11
- // cell carrying an `<img>` from the web captures as a blank rectangle.
12
- useCORS: true,
13
- });
14
- return sc.then(canvas => {
15
- const croppedCanvas = document.createElement('canvas');
16
- const croppedCanvasContext = croppedCanvas.getContext('2d');
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, TelescopeIcon, } from '@primer/octicons-react';
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 }), _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 }))] }));
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" })] }))), draws('agents') &&
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
- } }))), !loading && (_jsxs(Box, { sx: {
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',
@@ -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 }), _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 }) }))] }));
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.jupyterMcpServerUrl);
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." }) })] }))] }));
@@ -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.10",
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.0",
113
- "@datalayer/icons-react": "^1.0.7",
114
- "@datalayer/jupyter-lexical": "^1.0.16",
115
- "@datalayer/jupyter-react": "^2.0.7",
116
- "@datalayer/primer-addons": "^1.0.15",
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
- "html2canvas": "^1.4.1",
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 deterministic TypeScript wire types from the Jupyter MCP Server's
10
- OpenAPI document, on the `generate-contents-types.py` pattern.
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. `DATALAYER_JUPYTER_MCP_OPENAPI` names either a file or an URL and
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/jupyter-mcp-server"
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 = "datalayer_jupyter_mcp_server.main: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
- """The document of the application in a sibling checkout, in a subprocess.
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 / "datalayer_jupyter_mcp_server").is_dir():
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
- with urlopen(Request(url, headers={"Accept": "application/json"}), timeout=15) as response:
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("DATALAYER_JUPYTER_MCP_OPENAPI", "").strip()
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: