@datalayer/core 1.2.27 → 1.2.29
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/lib/api/evals/__tests__/external-definition.golden.json +110 -0
- package/lib/api/evals/client.d.ts +13 -1
- package/lib/api/evals/client.js +13 -0
- package/lib/api/evals/types.d.ts +168 -1
- package/lib/api/mcp/agentAccess.d.ts +10 -0
- package/lib/api/mcp/agentAccess.js +28 -0
- package/lib/api/mcp/generated.js +20 -0
- package/lib/api/mcp/index.d.ts +1 -0
- package/lib/api/mcp/index.js +1 -0
- package/lib/api/mcp/observability.d.ts +1 -1
- package/lib/api/mcp/observability.js +2 -0
- package/lib/api/mcp/operations.d.ts +28 -10
- package/lib/api/mcp/tasks.d.ts +9 -1
- package/lib/api/otel/firewall.d.ts +74 -0
- package/lib/api/otel/firewall.js +42 -0
- package/lib/api/otel/index.d.ts +1 -0
- package/lib/api/otel/index.js +1 -0
- package/lib/cli-login.js +2 -2
- package/lib/components/anonymous/AnonymousKeyExpired.js +15 -3
- package/lib/components/index.d.ts +1 -0
- package/lib/components/index.js +1 -0
- package/lib/components/mcp/ScopeList.js +10 -1
- package/lib/components/principal/PrincipalSwitcherMenu.js +4 -4
- package/lib/components/sharing/ShareAccessComponent.js +33 -8
- package/lib/components/toast/ToastPanel.d.ts +11 -0
- package/lib/components/toast/ToastPanel.js +42 -0
- package/lib/components/toast/index.d.ts +2 -0
- package/lib/components/toast/index.js +6 -0
- package/lib/components/toast/toastQueue.d.ts +35 -0
- package/lib/components/toast/toastQueue.js +47 -0
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.js +1 -0
- package/lib/hooks/useCache.d.ts +4 -2
- package/lib/hooks/useCache.js +13 -4
- package/lib/hooks/useDatalayer.js +12 -16
- package/lib/hooks/useKeptScroll.d.ts +6 -0
- package/lib/hooks/useKeptScroll.js +54 -0
- package/lib/hooks/useMcp.d.ts +2 -2
- package/lib/hooks/useMcp.js +5 -2
- package/lib/hooks/useNavigate.d.ts +16 -2
- package/lib/hooks/useNavigate.js +65 -5
- package/lib/hooks/useToast.d.ts +6 -0
- package/lib/hooks/useToast.js +40 -1
- package/lib/models/McpAgentAccess.d.ts +95 -0
- package/lib/models/McpAgentAccess.js +5 -0
- package/lib/models/Usage.d.ts +13 -0
- package/lib/models/Usage.js +5 -0
- package/lib/models/index.d.ts +1 -0
- package/lib/models/index.js +1 -0
- package/lib/otel/client/OtelClient.d.ts +56 -0
- package/lib/otel/client/OtelClient.js +25 -0
- package/lib/otel/client/index.d.ts +1 -1
- package/lib/otel/index.d.ts +1 -1
- package/lib/otel/views/OtelLive.js +3 -3
- package/lib/otel/views/OtelLiveSpans.js +2 -2
- package/lib/otel/views/OtelLogsList.js +3 -3
- package/lib/otel/views/OtelMetricsChart.d.ts +13 -0
- package/lib/otel/views/OtelMetricsChart.js +109 -26
- package/lib/otel/views/OtelSpanDetail.js +3 -3
- package/lib/otel/views/OtelSqlView.js +4 -4
- package/lib/otel/views/OtelSystemView.js +2 -2
- package/lib/otel/views/OtelTimelineRangeSlider.js +2 -2
- package/lib/otel/views/OtelTracesList.js +2 -2
- package/lib/state/substates/LayoutState.js +24 -3
- package/lib/state/substates/NavigationState.d.ts +11 -3
- package/lib/state/substates/NavigationState.js +48 -5
- package/lib/views/mcp/AuditLog.js +1 -1
- package/lib/views/mcp/ConnectedAgents.d.ts +1 -1
- package/lib/views/mcp/ConnectedAgents.js +13 -10
- package/lib/views/mcp/McpDashboard.d.ts +1 -1
- package/lib/views/mcp/McpDashboard.js +6 -6
- package/lib/views/mcp/McpHome.d.ts +3 -1
- package/lib/views/mcp/McpHome.js +19 -9
- package/lib/views/mcp/McpObservability.d.ts +22 -3
- package/lib/views/mcp/McpObservability.js +65 -17
- package/lib/views/mcp/Policies.js +1 -1
- package/lib/views/mcp/RunDetail.js +2 -2
- package/lib/views/mcp/types.d.ts +8 -4
- package/lib/views/otel/OtelHeader.js +2 -2
- package/package.json +2 -2
|
@@ -38,6 +38,9 @@ export const NAVIGATION_VIEW_HOMES = {
|
|
|
38
38
|
// of its own to be sent to. It was `/power` until 2026-10-09, when the
|
|
39
39
|
// shell took the name it is called by everywhere else.
|
|
40
40
|
home: '/platform',
|
|
41
|
+
// The spaces and what they hold: the Workspace of the Sessions shell,
|
|
42
|
+
// a shell of its own since 9 November 2026.
|
|
43
|
+
spaces: '/spaces',
|
|
41
44
|
agentify: '/agentify',
|
|
42
45
|
// The research areas and their spaces.
|
|
43
46
|
research: '/research',
|
|
@@ -60,6 +63,7 @@ const FORMER_TABS_KEY = 'datalayer_view_tabs';
|
|
|
60
63
|
const COOKIE_MAX_AGE_DAYS = 365;
|
|
61
64
|
const isView = (value) => value === 'studio' ||
|
|
62
65
|
value === 'home' ||
|
|
66
|
+
value === 'spaces' ||
|
|
63
67
|
value === 'agentify' ||
|
|
64
68
|
value === 'research' ||
|
|
65
69
|
value === 'admin' ||
|
|
@@ -243,17 +247,26 @@ export function isHostedAppRoute(route) {
|
|
|
243
247
|
(segments.length === 4 && segments[2] === 'preview')));
|
|
244
248
|
}
|
|
245
249
|
/**
|
|
246
|
-
* Whether a page belongs to no shell: the settings, the documentation
|
|
247
|
-
* the
|
|
250
|
+
* Whether a page belongs to no shell: the settings, the documentation, the
|
|
251
|
+
* presentations and the agentspecs catalogue (since 8 November 2026: reached
|
|
252
|
+
* from an agent's picker on /agents/new, its env var's page swapped the
|
|
253
|
+
* Sessions sidebar for the Studio's).
|
|
248
254
|
*
|
|
249
255
|
* They are read from whichever shell the reader is in — its sidebar stays
|
|
250
256
|
* beside them and the header's selector does not move — so they are never a
|
|
251
257
|
* shell's own page: opening one does not change the shell, and no shell
|
|
252
258
|
* returns to one.
|
|
259
|
+
*
|
|
260
|
+
* The presentations are `/decks/<collection>/<slug>` and their list,
|
|
261
|
+
* `/presentations`. Bare `/decks` is not one of them: it is the selected
|
|
262
|
+
* space's decks table, a page of the Spaces shell (the user, 12 November
|
|
263
|
+
* 2026). Neutral, it took the current shell whenever one was given, and the
|
|
264
|
+
* Sessions sidebar stayed beside it.
|
|
253
265
|
*/
|
|
254
266
|
export function isShellNeutralRoute(route) {
|
|
255
267
|
const path = pathOf(route);
|
|
256
|
-
return
|
|
268
|
+
return (path.startsWith('/decks/') ||
|
|
269
|
+
['/settings', '/docs', '/presentations', '/agentspecs'].some(prefix => path === prefix || path.startsWith(`${prefix}/`)));
|
|
257
270
|
}
|
|
258
271
|
/**
|
|
259
272
|
* The shell a route belongs to, read off its first segment.
|
|
@@ -275,8 +288,8 @@ export function viewForRoute(route, current) {
|
|
|
275
288
|
return current;
|
|
276
289
|
}
|
|
277
290
|
// The root is the Studio: what a signed-in reader opens on. The settings
|
|
278
|
-
// are read from it too, with its sidebar
|
|
279
|
-
//
|
|
291
|
+
// and the agentspecs catalogue are read from it too, with its sidebar
|
|
292
|
+
// beside them, when no shell is current.
|
|
280
293
|
if (path === '/' ||
|
|
281
294
|
path === '/studio' ||
|
|
282
295
|
path.startsWith('/studio/') ||
|
|
@@ -286,6 +299,10 @@ export function viewForRoute(route, current) {
|
|
|
286
299
|
path.startsWith('/agentspecs/')) {
|
|
287
300
|
return 'studio';
|
|
288
301
|
}
|
|
302
|
+
if (SPACES_SHELL_ROUTES.some(prefix => path === prefix || path.startsWith(`${prefix}/`)) ||
|
|
303
|
+
SPACES_SHELL_EXACT_ROUTES.includes(path)) {
|
|
304
|
+
return 'spaces';
|
|
305
|
+
}
|
|
289
306
|
if (path === '/agentify' || path.startsWith('/agentify/')) {
|
|
290
307
|
return 'agentify';
|
|
291
308
|
}
|
|
@@ -315,6 +332,32 @@ export function viewForRoute(route, current) {
|
|
|
315
332
|
}
|
|
316
333
|
return 'home';
|
|
317
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* The Spaces shell's pages: the spaces, then the work they hold — what was
|
|
337
|
+
* the Workspace of the Sessions sidebar until 9 November 2026, when it became
|
|
338
|
+
* a shell of its own.
|
|
339
|
+
*/
|
|
340
|
+
const SPACES_SHELL_ROUTES = [
|
|
341
|
+
'/spaces',
|
|
342
|
+
'/items',
|
|
343
|
+
// The Library, public and private, beside the Spaces sidebar (the user,
|
|
344
|
+
// 9 November 2026).
|
|
345
|
+
'/library',
|
|
346
|
+
'/shared',
|
|
347
|
+
'/notebooks',
|
|
348
|
+
'/documents',
|
|
349
|
+
'/cells',
|
|
350
|
+
// The selected space's spreadsheets (the user, 12 November 2026).
|
|
351
|
+
'/spreadsheets',
|
|
352
|
+
'/datasets',
|
|
353
|
+
'/contents',
|
|
354
|
+
];
|
|
355
|
+
/**
|
|
356
|
+
* The selected space's decks, `/decks` itself only (the user, 12 November
|
|
357
|
+
* 2026): `/decks/<collection>/<slug>` are presentations, drawn by their own
|
|
358
|
+
* page, not the Spaces shell's.
|
|
359
|
+
*/
|
|
360
|
+
const SPACES_SHELL_EXACT_ROUTES = ['/decks'];
|
|
318
361
|
/**
|
|
319
362
|
* `/<account>/<space>/<kind>[/…]` for the kinds a space shows beside the
|
|
320
363
|
* Agentify sidebar — decks, spreadsheets (Cells), courses and what a course
|
|
@@ -87,7 +87,7 @@ export const AuditLog = ({ errorState, routes, filters, onFiltersChange, retenti
|
|
|
87
87
|
}), children: [_jsx(Select.Option, { value: "", children: "Every decision" }), _jsx(Select.Option, { value: "allowed", children: "Allowed" }), _jsx(Select.Option, { value: "refused", children: "Refused" })] }), _jsxs(Select, { size: "small", "aria-label": "Outcome", value: filters.outcome ?? '', onChange: event => set({
|
|
88
88
|
outcome: (event.currentTarget.value ||
|
|
89
89
|
undefined),
|
|
90
|
-
}), children: [_jsx(Select.Option, { value: "", children: "Every outcome" }), _jsx(Select.Option, { value: "ok", children: "Succeeded" }), _jsx(Select.Option, { value: "error", children: "Failed" }), _jsx(Select.Option, { value: "is_error", children: "Tool error" })] }), _jsx(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: _jsxs(ActionMenu, { children: [_jsx(ActionMenu.Button, { size: "small", leadingVisual: DownloadIcon, disabled: exporting.isPending, children: "Export" }), _jsx(ActionMenu.Overlay, { align: "end", children: _jsxs(ActionList, { children: [_jsxs(ActionList.Item, { onSelect: () => exportAs('jsonl'), children: ["JSONL", _jsx(ActionList.Description, { variant: "block", children: "One row per line, as a SIEM reads it." })] }), _jsxs(ActionList.Item, { onSelect: () => exportAs('csv'), children: ["CSV", _jsx(ActionList.Description, { variant: "block", children: "The same rows for a spreadsheet." })] })] }) })] }) })] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: ["Audit rows are written once and never changed or deleted by anyone, here or elsewhere. Arguments are redacted before they are written.", ' ', retention] }), page.isPending && !page.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 6, children: _jsx(Spinner, {}) })) : rows.length === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(HistoryIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "Nothing to audit yet" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Every call an agent makes is written down here with its decision and its outcome. Connect a client and ask it to list your notebooks." }) }), _jsx(Button, { size: "small", onClick: () => navigate(routes.
|
|
90
|
+
}), children: [_jsx(Select.Option, { value: "", children: "Every outcome" }), _jsx(Select.Option, { value: "ok", children: "Succeeded" }), _jsx(Select.Option, { value: "error", children: "Failed" }), _jsx(Select.Option, { value: "is_error", children: "Tool error" })] }), _jsx(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: _jsxs(ActionMenu, { children: [_jsx(ActionMenu.Button, { size: "small", leadingVisual: DownloadIcon, disabled: exporting.isPending, children: "Export" }), _jsx(ActionMenu.Overlay, { align: "end", children: _jsxs(ActionList, { children: [_jsxs(ActionList.Item, { onSelect: () => exportAs('jsonl'), children: ["JSONL", _jsx(ActionList.Description, { variant: "block", children: "One row per line, as a SIEM reads it." })] }), _jsxs(ActionList.Item, { onSelect: () => exportAs('csv'), children: ["CSV", _jsx(ActionList.Description, { variant: "block", children: "The same rows for a spreadsheet." })] })] }) })] }) })] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: ["Audit rows are written once and never changed or deleted by anyone, here or elsewhere. Arguments are redacted before they are written.", ' ', retention] }), page.isPending && !page.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 6, children: _jsx(Spinner, {}) })) : rows.length === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(HistoryIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "Nothing to audit yet" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Every call an agent makes is written down here with its decision and its outcome. Connect a client and ask it to list your notebooks." }) }), _jsx(Button, { size: "small", onClick: () => navigate(routes.configuration), children: "Connect an agent" })] })) : (
|
|
91
91
|
/* The rail of the activity view: each mark on a tinted disc, the discs
|
|
92
92
|
linked by a line, so the rows carry no border of their own. */
|
|
93
93
|
_jsx(Box, { display: "grid", children: rows.map((event, index) => {
|
|
@@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { ActionList, ActionMenu, Button, Heading, Label, RelativeTime, 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 { KebabHorizontalIcon,
|
|
6
|
+
import { KebabHorizontalIcon, McpIcon } from '@primer/octicons-react';
|
|
7
7
|
import { ClientBadge, McpErrorBlankslate, ScopeList, } from '../../components/mcp/index.js';
|
|
8
8
|
import { useConnectedAgents, useDisconnectAgent, useDisconnectEveryAgent, } from '../../hooks/useMcp.js';
|
|
9
9
|
import { useNavigate, useToast } from '../../hooks/index.js';
|
|
@@ -24,8 +24,8 @@ export const ConnectedAgents = ({ errorState, routes, showTitle = true, readOnly
|
|
|
24
24
|
disconnectEvery.mutate(undefined, {
|
|
25
25
|
onSuccess: answer => {
|
|
26
26
|
enqueueToast(answer.revoked === 1
|
|
27
|
-
? '1
|
|
28
|
-
: `${answer.revoked}
|
|
27
|
+
? '1 client disconnected.'
|
|
28
|
+
: `${answer.revoked} clients disconnected.`, { variant: 'success' });
|
|
29
29
|
setDisconnectingEvery(false);
|
|
30
30
|
},
|
|
31
31
|
onError: error => {
|
|
@@ -75,19 +75,22 @@ export const ConnectedAgents = ({ errorState, routes, showTitle = true, readOnly
|
|
|
75
75
|
{
|
|
76
76
|
header: 'Allowed to',
|
|
77
77
|
id: 'scopes',
|
|
78
|
-
|
|
78
|
+
// Room for its scopes, which wrap inside the cell rather than run
|
|
79
|
+
// over the dates beside it (30 October 2026).
|
|
80
|
+
width: 'grow',
|
|
81
|
+
minWidth: '240px',
|
|
79
82
|
renderCell: row => _jsx(ScopeList, { scopes: row.scopes }),
|
|
80
83
|
},
|
|
81
84
|
{
|
|
82
85
|
header: 'Connected',
|
|
83
86
|
id: 'created',
|
|
84
|
-
width: '
|
|
87
|
+
width: '140px',
|
|
85
88
|
renderCell: row => row.createdAt ? (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: _jsx(RelativeTime, { datetime: row.createdAt }) })) : (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "\u2014" })),
|
|
86
89
|
},
|
|
87
90
|
{
|
|
88
91
|
header: 'Last used',
|
|
89
92
|
id: 'last-used',
|
|
90
|
-
width: '
|
|
93
|
+
width: '140px',
|
|
91
94
|
renderCell: row => row.lastUsedAt ? (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: _jsx(RelativeTime, { datetime: row.lastUsedAt }) })) : (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Never" })),
|
|
92
95
|
},
|
|
93
96
|
{
|
|
@@ -99,9 +102,9 @@ export const ConnectedAgents = ({ errorState, routes, showTitle = true, readOnly
|
|
|
99
102
|
},
|
|
100
103
|
];
|
|
101
104
|
if (agents.isError) {
|
|
102
|
-
return (_jsx(McpErrorBlankslate, { state: errorState(agents.error, '
|
|
105
|
+
return (_jsx(McpErrorBlankslate, { state: errorState(agents.error, 'Authorized clients'), onRetry: () => agents.refetch() }));
|
|
103
106
|
}
|
|
104
|
-
return (_jsxs(Box, { display: "grid", gap: 3, minWidth: 0, children: [showTitle && (_jsxs(Box, { display: "flex", alignItems: "start", justifyContent: "space-between", gap: 3, children: [_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "
|
|
107
|
+
return (_jsxs(Box, { display: "grid", gap: 3, minWidth: 0, children: [showTitle && (_jsxs(Box, { display: "flex", alignItems: "start", justifyContent: "space-between", gap: 3, children: [_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "Authorized clients" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: "Every MCP client you have authorized, what it may do, and when it last did it." })] }), !readOnly && rows.length > 0 && (_jsx(Button, { variant: "danger", onClick: () => setDisconnectingEvery(true), disabled: disconnectEvery.isPending, children: "Disconnect all" }))] })), agents.isPending && !agents.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 5, children: _jsx(Spinner, {}) })) : rows.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "authorized-clients", children: "Clients" }), _jsx(Table.Subtitle, { as: "p", id: "authorized-clients-subtitle", children: "A client acts with exactly the access of the person who authorized it, and no more." }), _jsx(DataTable, { "aria-labelledby": "authorized-clients", "aria-describedby": "authorized-clients-subtitle", data: rows, columns: columns })] })) : (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(McpIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "No client authorized" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Connect an MCP client and approve the scopes it asks for; the grant appears here and can be revoked from here." }) }), _jsx(Button, { size: "small", onClick: () => navigate(routes.configuration), children: "Connect a client" })] })), _jsx(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: "Narrowing a grant to fewer scopes without disconnecting arrives with the organization policy." }), disconnecting && (_jsx(Dialog, { title: "Disconnect this client?", onClose: () => setDisconnecting(null), returnFocusRef: returnFocusRef, footerButtons: [
|
|
105
108
|
{
|
|
106
109
|
buttonType: 'default',
|
|
107
110
|
content: 'Keep it',
|
|
@@ -113,7 +116,7 @@ export const ConnectedAgents = ({ errorState, routes, showTitle = true, readOnly
|
|
|
113
116
|
onClick: confirmDisconnect,
|
|
114
117
|
disabled: disconnect.isPending,
|
|
115
118
|
},
|
|
116
|
-
], children: _jsxs(Text, { sx: { fontSize: 1 }, children: [disconnecting.clientName || disconnecting.clientId, " loses its grant at once and its next call is refused. An access token it still holds dies with its own short expiry. The other
|
|
119
|
+
], children: _jsxs(Text, { sx: { fontSize: 1 }, children: [disconnecting.clientName || disconnecting.clientId, " loses its grant at once and its next call is refused. An access token it still holds dies with its own short expiry. The other clients are untouched, and nothing this one already did is undone."] }) })), disconnectingEvery && (_jsxs(Dialog, { title: "Disconnect every client?", onClose: () => setDisconnectingEvery(false), returnFocusRef: returnFocusRef, footerButtons: [
|
|
117
120
|
{
|
|
118
121
|
buttonType: 'default',
|
|
119
122
|
content: 'Keep them',
|
|
@@ -125,6 +128,6 @@ export const ConnectedAgents = ({ errorState, routes, showTitle = true, readOnly
|
|
|
125
128
|
onClick: confirmDisconnectEvery,
|
|
126
129
|
disabled: disconnectEvery.isPending,
|
|
127
130
|
},
|
|
128
|
-
], children: [_jsx(Text, { sx: { fontSize: 1 }, children: "Every
|
|
131
|
+
], children: [_jsx(Text, { sx: { fontSize: 1 }, children: "Every client you have authorized loses its grant at once \u2014 signing out everywhere. Each will ask again the next time you connect it, and this time it goes through the approval screen rather than being answered from what you agreed before, so this is also how you force a fresh approval." }), _jsx(Text, { as: "p", sx: { fontSize: 1, color: 'fg.muted', mt: 2, mb: 0 }, children: "Yours only. Nobody else\u2019s sessions end, in this organization or any other, and nothing any client already did is undone." })] }))] }));
|
|
129
132
|
};
|
|
130
133
|
export default ConnectedAgents;
|
|
@@ -45,6 +45,6 @@ export interface McpDashboardProps {
|
|
|
45
45
|
section: McpDashboardSection;
|
|
46
46
|
}
|
|
47
47
|
/** The parts of the dashboard, each its own tab. */
|
|
48
|
-
export type McpDashboardSection = 'overview' | '
|
|
48
|
+
export type McpDashboardSection = 'overview' | 'clients' | 'runs' | 'sandboxes' | 'calls';
|
|
49
49
|
export declare const McpDashboard: ({ errorState, routes, org, showTitle, section, }: McpDashboardProps) => JSX.Element;
|
|
50
50
|
export default McpDashboard;
|
|
@@ -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, Card } from '@datalayer/primer-addons';
|
|
6
|
-
import { ChecklistIcon, KebabHorizontalIcon, PlayIcon,
|
|
6
|
+
import { ChecklistIcon, KebabHorizontalIcon, PlayIcon, McpIcon, PackageIcon, } 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';
|
|
@@ -375,17 +375,17 @@ export const McpDashboard = ({ errorState, routes, org, showTitle = true, sectio
|
|
|
375
375
|
'repeat(2, 1fr)',
|
|
376
376
|
'repeat(3, 1fr)',
|
|
377
377
|
'repeat(6, 1fr)',
|
|
378
|
-
], children: [_jsx(Count, { label: "Clients connected", value: clients.length, to: routes.
|
|
378
|
+
], children: [_jsx(Count, { label: "Clients connected", value: clients.length, to: routes.clients, 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",
|
|
379
379
|
// Credits are summed from the day's calls, which the Calls tab
|
|
380
380
|
// lists.
|
|
381
|
-
to: routes.calls, onOpen: navigate })] }))), draws('
|
|
381
|
+
to: routes.calls, onOpen: navigate })] }))), draws('clients') &&
|
|
382
382
|
!loading &&
|
|
383
|
-
(clients.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-clients", children: "
|
|
383
|
+
(clients.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-clients", children: "Live connections" }), _jsxs(Table.Subtitle, { as: "p", id: "mcp-clients-subtitle", children: [plural(clients.length, 'client'), " connected to your workspace now, with its status and the last tool it called."] }), _jsx(DataTable, { "aria-labelledby": "mcp-clients", "aria-describedby": "mcp-clients-subtitle", data: clients, columns: clientColumns })] })) : (_jsx(PanelEmpty, { icon: McpIcon, 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: {
|
|
384
384
|
label: 'Set up a client',
|
|
385
|
-
onClick: () => navigate(routes.
|
|
385
|
+
onClick: () => navigate(routes.configuration),
|
|
386
386
|
} }))), draws('runs') && !loading && (_jsx(Box, { children: tasks.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-runs", children: "Running now" }), _jsx(Table.Subtitle, { as: "p", id: "mcp-runs-subtitle", children: "A tool call that outlives its request. Open one to watch its output, cancel it, or answer it when it is waiting on you." }), _jsx(DataTable, { "aria-labelledby": "mcp-runs", "aria-describedby": "mcp-runs-subtitle", data: tasks, columns: taskColumns })] })) : (_jsx(PanelEmpty, { icon: PlayIcon, heading: "Nothing is running", description: "A tool call that outlives its request becomes a task you can watch, cancel and answer. Ask a client for one \u2014 an agent that runs a long cell \u2014 and it appears here while it runs." })) })), draws('sandboxes') &&
|
|
387
387
|
!loading &&
|
|
388
|
-
(sandboxes.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-sandboxes", children: "Sandboxes bound" }), _jsx(Table.Subtitle, { as: "p", id: "mcp-sandboxes-subtitle", children: "The sessions your agents execute in. Terminating one ends its open work." }), _jsx(DataTable, { "aria-labelledby": "mcp-sandboxes", "aria-describedby": "mcp-sandboxes-subtitle", data: sandboxes, columns: sandboxColumns })] })) : (_jsx(PanelEmpty, { icon:
|
|
388
|
+
(sandboxes.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-sandboxes", children: "Sandboxes bound" }), _jsx(Table.Subtitle, { as: "p", id: "mcp-sandboxes-subtitle", children: "The sessions your agents execute in. Terminating one ends its open work." }), _jsx(DataTable, { "aria-labelledby": "mcp-sandboxes", "aria-describedby": "mcp-sandboxes-subtitle", data: sandboxes, columns: sandboxColumns })] })) : (_jsx(PanelEmpty, { icon: PackageIcon, heading: "No Code Sandbox bound", description: "An agent that launches a sandbox keeps it for the session; it appears here with its provider, its capabilities and what it is doing." }))), draws('calls') &&
|
|
389
389
|
!loading &&
|
|
390
390
|
(calls.length > 0 ? (_jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h3", id: "mcp-calls", children: "Recent calls" }), _jsx(Table.Subtitle, { as: "p", id: "mcp-calls-subtitle", children: "The last calls as they landed. Every one of them has an audit row that outlives this page." }), _jsx(Table.Actions, { children: _jsxs(Box, { display: "flex", gap: 2, alignItems: "center", children: [_jsxs(Select, { size: "small", "aria-label": "Filter by client", value: callClient, onChange: event => setCallClient(event.currentTarget.value), children: [_jsx(Select.Option, { value: "", children: "Every client" }), clients.map(client => (_jsx(Select.Option, { value: client.clientId, children: client.clientName || client.clientId }, client.clientId)))] }), _jsxs(Select, { size: "small", "aria-label": "Filter by tool", value: callTool, onChange: event => setCallTool(event.currentTarget.value), children: [_jsx(Select.Option, { value: "", children: "Every tool" }), callTools.map(tool => (_jsx(Select.Option, { value: tool, children: tool }, tool)))] }), _jsxs(Select, { size: "small", "aria-label": "Filter by outcome", value: callOutcome, onChange: event => setCallOutcome(event.currentTarget.value), children: [_jsx(Select.Option, { value: "", children: "Every outcome" }), _jsx(Select.Option, { value: "ok", children: "Succeeded" }), _jsx(Select.Option, { value: "error", children: "Failed" }), _jsx(Select.Option, { value: "refused", children: "Refused" })] })] }) }), filteredCalls.length > 0 ? (_jsx(DataTable, { "aria-labelledby": "mcp-calls", "aria-describedby": "mcp-calls-subtitle", data: filteredCalls, columns: callColumns })) : (_jsx(Box, { p: 4, textAlign: "center", color: "fg.muted", fontSize: 1, 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: {
|
|
391
391
|
label: 'Open the audit log',
|
|
@@ -85,8 +85,10 @@ export declare const MCP_CLIENTS: {
|
|
|
85
85
|
* does: a reader who has seen one and then the other should not have to
|
|
86
86
|
* work out whether they are the same affordance.
|
|
87
87
|
*/
|
|
88
|
-
export declare const Copyable: ({ text }: {
|
|
88
|
+
export declare const Copyable: ({ text, compact, }: {
|
|
89
89
|
text: string;
|
|
90
|
+
/** As wide as the text, with the button right after it. */
|
|
91
|
+
compact?: boolean;
|
|
90
92
|
}) => JSX.Element;
|
|
91
93
|
export declare const McpHome: ({ routes, clientDocsBase, showTitle, showSummaries, showSetup, onOpenDocs, }: McpHomeProps) => JSX.Element;
|
|
92
94
|
export default McpHome;
|
package/lib/views/mcp/McpHome.js
CHANGED
|
@@ -39,12 +39,12 @@ export const MCP_CLIENTS = [
|
|
|
39
39
|
* does: a reader who has seen one and then the other should not have to
|
|
40
40
|
* work out whether they are the same affordance.
|
|
41
41
|
*/
|
|
42
|
-
export const Copyable = ({ text }) => {
|
|
42
|
+
export const Copyable = ({ text, compact = false, }) => {
|
|
43
43
|
const [copied, setCopied] = useState(false);
|
|
44
|
-
return (_jsxs(Box, { display:
|
|
44
|
+
return (_jsxs(Box, { display: compact ? 'inline-flex' : 'flex', justifySelf: compact ? 'start' : undefined, maxWidth: "100%", alignItems: "center", gap: 2, p: 2, bg: "canvas.inset", borderRadius: 2, minWidth: 0, children: [_jsx(Text, { sx: {
|
|
45
45
|
fontFamily: 'mono',
|
|
46
46
|
fontSize: 0,
|
|
47
|
-
flex: 1,
|
|
47
|
+
flex: compact ? undefined : 1,
|
|
48
48
|
minWidth: 0,
|
|
49
49
|
overflow: 'hidden',
|
|
50
50
|
textOverflow: 'ellipsis',
|
|
@@ -79,7 +79,7 @@ export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitl
|
|
|
79
79
|
const live = activity.data?.clients.length ?? 0;
|
|
80
80
|
return (_jsxs(Box, { display: "grid", gap: 4, minWidth: 0, children: [showTitle && (_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "MCP" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: "Connect your agents to your notebooks, data and sandboxes through the Model Context Protocol." })] })), showSetup && (_jsxs(_Fragment, { children: [_jsxs(Card, { border: true, shadow: "none", variant: "subtle", flexDirection: "row", display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap", p: 3, children: [_jsx(Text, { sx: { fontSize: 1 }, children: live > 0
|
|
81
81
|
? `${plural(live, 'client')} connected right now.`
|
|
82
|
-
: 'No client is connected right now.' }), _jsx(Button, { size: "small", leadingVisual: PulseIcon, onClick: () => navigate(routes.dashboard), children: "What is going on" })] }), _jsxs(Box, { display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2 }, children: "The endpoint" }), _jsx(Copyable, { text: endpoint || 'https://mcp.datalayer.run/mcp' }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 2, children: [_jsx(KeyIcon, { size: 14 }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: signedIn
|
|
82
|
+
: 'No client is connected right now.' }), _jsx(Button, { size: "small", leadingVisual: PulseIcon, onClick: () => navigate(routes.dashboard), children: "What is going on" })] }), _jsxs(Box, { display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2 }, children: "The endpoint" }), _jsx(Copyable, { compact: true, text: endpoint || 'https://mcp.datalayer.run/mcp' }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 2, children: [_jsx(KeyIcon, { size: 14 }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: signedIn
|
|
83
83
|
? 'You are signed in, so a client you set up from here authorizes through OAuth without a shared token.'
|
|
84
84
|
: 'Sign in to authorize a client through OAuth; without it a client can only use a personal access token.' })] })] }), _jsxs(Box, { display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2 }, children: "Set up a client" }), _jsx(Box, { display: "grid", gap: 3, gridTemplateColumns: ['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)'], children: MCP_CLIENTS.map(client => (_jsxs(Card, { border: true, shadow: "none", p: 3, display: "grid", gap: 2, minWidth: 0, children: [_jsx(Text, { sx: { fontSize: 1, fontWeight: 'semibold' }, children: client.name }), _jsx(Copyable, { text: mcpSetupCommand(client.setup) }), _jsx(Link, { href: `${clientDocsBase}/${client.setup}`, sx: { fontSize: 0, cursor: 'pointer', textAlign: 'left' }, onClick: event => {
|
|
85
85
|
// A modified click is the reader asking for a new tab or
|
|
@@ -91,10 +91,10 @@ export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitl
|
|
|
91
91
|
}
|
|
92
92
|
event.preventDefault();
|
|
93
93
|
onOpenDocs(`${clientDocsBase}/${client.setup}`);
|
|
94
|
-
}, children: "Configuration and authorization" })] }, client.setup))) })] })] })), showSummaries && (_jsxs(Box, { display: "grid", gap: 3, gridTemplateColumns: ['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)'], children: [_jsx(Summary, { icon: AiAgentIcon, title: "
|
|
95
|
-
label: '
|
|
96
|
-
onClick: () => navigate(routes.
|
|
97
|
-
}, children: connected.length === 0 ? (_jsx(Text, { children: "No
|
|
94
|
+
}, children: "Configuration and authorization" })] }, client.setup))) })] })] })), showSummaries && (_jsxs(Box, { display: "grid", gap: 3, gridTemplateColumns: ['1fr', 'repeat(2, 1fr)', 'repeat(3, 1fr)'], children: [_jsx(Summary, { icon: AiAgentIcon, title: "Clients", to: routes.clients, onOpen: navigate, action: {
|
|
95
|
+
label: 'Authorized clients',
|
|
96
|
+
onClick: () => navigate(routes.clients),
|
|
97
|
+
}, children: connected.length === 0 ? (_jsx(Text, { children: "No client 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, 'client'), " authorized."] }), connected.slice(0, 3).map(agent => (_jsxs(Text, { children: [agent.clientName || agent.clientId, " \u2014", ' ', agent.lastUsedAt
|
|
98
98
|
? `used ${timeAgo(agent.lastUsedAt)}`
|
|
99
99
|
: '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: {
|
|
100
100
|
label: 'Runs and metrics',
|
|
@@ -106,7 +106,17 @@ export const McpHome = ({ routes, clientDocsBase = '/docs/mcp-clients', showTitl
|
|
|
106
106
|
: 'Latency not measured yet.' }), _jsx(Text, { children: metrics.data?.slis.availability === null ||
|
|
107
107
|
metrics.data?.slis.availability === undefined
|
|
108
108
|
? 'Error rate not measured yet.'
|
|
109
|
-
: `${((1 - metrics.data.slis.availability) * 100).toFixed(1)}% of calls failed.` })] }), _jsx(Summary, { icon: ToolsIcon, title: "Tools",
|
|
109
|
+
: `${((1 - metrics.data.slis.availability) * 100).toFixed(1)}% of calls failed.` })] }), _jsx(Summary, { icon: ToolsIcon, title: "Tools", to: routes.tools, onOpen: navigate, action: routes.tools
|
|
110
|
+
? {
|
|
111
|
+
label: 'What each agent may execute',
|
|
112
|
+
onClick: () => navigate(routes.tools),
|
|
113
|
+
}
|
|
114
|
+
: undefined, children: _jsx(Text, { children: "The toolsets each of your agents may execute, with their tools, their approval policy and their health." }) }), _jsx(Summary, { icon: RssIcon, title: "Reach", to: routes.reach, onOpen: navigate, action: routes.reach
|
|
115
|
+
? {
|
|
116
|
+
label: 'What each agent reaches',
|
|
117
|
+
onClick: () => navigate(routes.reach),
|
|
118
|
+
}
|
|
119
|
+
: undefined, children: _jsx(Text, { children: "The notebooks and sources each of your agents can actually reach, and whether it may read, edit or run them." }) }), _jsx(Summary, { icon: ShieldCheckIcon, title: "Policies", to: routes.policies, onOpen: navigate, action: {
|
|
110
120
|
label: 'What applies',
|
|
111
121
|
onClick: () => navigate(routes.policies),
|
|
112
122
|
}, children: _jsx(Text, { children: "What your agents are allowed to do, and which layer decided each rule." }) })] }))] }));
|
|
@@ -24,6 +24,8 @@ import type { JSX } from 'react';
|
|
|
24
24
|
import type { McpWorkflowsHealth } from '../../api/mcp/operations';
|
|
25
25
|
import { type McpErrorStateFn, type McpRoutes } from './types';
|
|
26
26
|
export type McpObservabilityPane = 'runs' | 'live' | 'metrics';
|
|
27
|
+
/** Every pane, in the order the switcher shows them. */
|
|
28
|
+
export declare const ALL_PANES: readonly McpObservabilityPane[];
|
|
27
29
|
export interface McpObservabilityProps {
|
|
28
30
|
/** The application's words for a failed request. */
|
|
29
31
|
errorState: McpErrorStateFn;
|
|
@@ -32,6 +34,13 @@ export interface McpObservabilityProps {
|
|
|
32
34
|
/** Which pane is open; the address owns it. */
|
|
33
35
|
pane: McpObservabilityPane;
|
|
34
36
|
onPaneChange: (pane: McpObservabilityPane) => void;
|
|
37
|
+
/**
|
|
38
|
+
* The panes offered, in this order; all three when unsaid. The Live pane
|
|
39
|
+
* reads the platform's own engine and is a platform administrator's
|
|
40
|
+
* (the user, 6 November 2026): an application shows an account its runs
|
|
41
|
+
* and metrics, and its admin page the live engine.
|
|
42
|
+
*/
|
|
43
|
+
panes?: readonly McpObservabilityPane[];
|
|
35
44
|
/** The run to open, when the address names one. */
|
|
36
45
|
taskUid?: string;
|
|
37
46
|
/** The trace to open — what a synchronous call leaves behind. */
|
|
@@ -67,13 +76,23 @@ export declare const stageOf: (name: string) => string;
|
|
|
67
76
|
* second is usually a problem with the network between them, and sending
|
|
68
77
|
* somebody to the wrong one costs an hour.
|
|
69
78
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
79
|
+
* An engine taking work is healthy; a queue it was told to run that nobody
|
|
80
|
+
* serves is drawn as a warning beside it — work sent there is accepted and
|
|
81
|
+
* never done. The engine reports no backlog of its own (DBOS's pending and
|
|
82
|
+
* enqueued are both `running`), so what is running is counted among the
|
|
83
|
+
* newest runs it lists, and said as such.
|
|
72
84
|
*/
|
|
73
85
|
export declare const engineLook: (health: McpWorkflowsHealth | undefined, failed: boolean) => {
|
|
74
86
|
label: string;
|
|
75
87
|
variant: "success" | "attention" | "danger" | "secondary";
|
|
76
88
|
note: string;
|
|
77
89
|
};
|
|
78
|
-
|
|
90
|
+
/** Each queue the engine polls, with how many of its newest runs are running there and the oldest of them. */
|
|
91
|
+
export declare const engineQueues: (health: McpWorkflowsHealth | undefined, now?: number) => {
|
|
92
|
+
name: string;
|
|
93
|
+
running: number;
|
|
94
|
+
oldestSeconds?: number;
|
|
95
|
+
served: boolean;
|
|
96
|
+
}[];
|
|
97
|
+
export declare const McpObservability: ({ errorState, routes, pane, onPaneChange, panes, taskUid, traceId, onSelect, agent, org, showTitle, }: McpObservabilityProps) => JSX.Element;
|
|
79
98
|
export default McpObservability;
|
|
@@ -10,6 +10,17 @@ import { TraceTimeline } from './TraceTimeline.js';
|
|
|
10
10
|
import { useNavigate } from '../../hooks/index.js';
|
|
11
11
|
import { MCP_METRIC_CATALOG } from '../../api/mcp/index.js';
|
|
12
12
|
import { durationLabel, timeAgo } from './format.js';
|
|
13
|
+
/** Every pane, in the order the switcher shows them. */
|
|
14
|
+
export const ALL_PANES = [
|
|
15
|
+
'runs',
|
|
16
|
+
'live',
|
|
17
|
+
'metrics',
|
|
18
|
+
];
|
|
19
|
+
const PANE_LABELS = {
|
|
20
|
+
runs: 'Runs',
|
|
21
|
+
live: 'Live',
|
|
22
|
+
metrics: 'Metrics',
|
|
23
|
+
};
|
|
13
24
|
/**
|
|
14
25
|
* What part of the platform a span belongs to, from its name.
|
|
15
26
|
*
|
|
@@ -71,8 +82,11 @@ const Sli = ({ label, value, note, }) => (_jsxs(Card, { border: true, shadow: "n
|
|
|
71
82
|
* second is usually a problem with the network between them, and sending
|
|
72
83
|
* somebody to the wrong one costs an hour.
|
|
73
84
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
85
|
+
* An engine taking work is healthy; a queue it was told to run that nobody
|
|
86
|
+
* serves is drawn as a warning beside it — work sent there is accepted and
|
|
87
|
+
* never done. The engine reports no backlog of its own (DBOS's pending and
|
|
88
|
+
* enqueued are both `running`), so what is running is counted among the
|
|
89
|
+
* newest runs it lists, and said as such.
|
|
76
90
|
*/
|
|
77
91
|
export const engineLook = (health, failed) => {
|
|
78
92
|
if (failed) {
|
|
@@ -85,26 +99,58 @@ export const engineLook = (health, failed) => {
|
|
|
85
99
|
if (!health) {
|
|
86
100
|
return { label: 'Asking…', variant: 'secondary', note: '' };
|
|
87
101
|
}
|
|
88
|
-
|
|
89
|
-
if (!health.healthy) {
|
|
102
|
+
if (!health.durable) {
|
|
90
103
|
return {
|
|
91
104
|
label: 'Unhealthy',
|
|
92
105
|
variant: 'danger',
|
|
93
|
-
note: health.detail || 'The engine reports
|
|
106
|
+
note: health.detail || 'The engine reports it is not taking work.',
|
|
94
107
|
};
|
|
95
108
|
}
|
|
109
|
+
const unserved = health.queuesNobodyServes ?? [];
|
|
110
|
+
if (unserved.length > 0) {
|
|
111
|
+
return {
|
|
112
|
+
label: 'Healthy',
|
|
113
|
+
variant: 'attention',
|
|
114
|
+
note: `Nobody serves ${unserved.join(', ')}: work sent there is accepted and never done.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const running = (health.runs ?? []).filter(run => run.status === 'running').length;
|
|
96
118
|
return {
|
|
97
119
|
label: 'Healthy',
|
|
98
|
-
variant:
|
|
99
|
-
note:
|
|
100
|
-
? `${
|
|
101
|
-
: '
|
|
120
|
+
variant: 'success',
|
|
121
|
+
note: running > 0
|
|
122
|
+
? `${running} of its newest runs ${running === 1 ? 'is' : 'are'} running.`
|
|
123
|
+
: 'None of its newest runs is running.',
|
|
102
124
|
};
|
|
103
125
|
};
|
|
126
|
+
/** Each queue the engine polls, with how many of its newest runs are running there and the oldest of them. */
|
|
127
|
+
export const engineQueues = (health, now = Date.now()) => {
|
|
128
|
+
if (!health)
|
|
129
|
+
return [];
|
|
130
|
+
const unserved = health.queuesNobodyServes ?? [];
|
|
131
|
+
const names = [
|
|
132
|
+
...(health.queues ?? []),
|
|
133
|
+
...unserved.filter(name => !(health.queues ?? []).includes(name)),
|
|
134
|
+
];
|
|
135
|
+
return names.map(name => {
|
|
136
|
+
const running = (health.runs ?? []).filter(run => run.queue === name && run.status === 'running');
|
|
137
|
+
const started = running
|
|
138
|
+
.map(run => run.startedAt)
|
|
139
|
+
.filter((at) => typeof at === 'number');
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
running: running.length,
|
|
143
|
+
oldestSeconds: started.length
|
|
144
|
+
? Math.max(0, Math.round(now / 1000 - Math.min(...started)))
|
|
145
|
+
: undefined,
|
|
146
|
+
served: !unserved.includes(name),
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
};
|
|
104
150
|
const percentLabel = (value) => value === null || value === undefined
|
|
105
151
|
? 'Not measured'
|
|
106
152
|
: `${(value * 100).toFixed(1)}%`;
|
|
107
|
-
export const McpObservability = ({ errorState, routes, pane, onPaneChange, taskUid, traceId, onSelect, agent, org, showTitle = true, }) => {
|
|
153
|
+
export const McpObservability = ({ errorState, routes, pane, onPaneChange, panes = ALL_PANES, taskUid, traceId, onSelect, agent, org, showTitle = true, }) => {
|
|
108
154
|
const navigate = useNavigate();
|
|
109
155
|
const [find, setFind] = useState(taskUid ?? traceId ?? '');
|
|
110
156
|
// One of the two, never both: a task names its own trace, and a trace
|
|
@@ -120,7 +166,7 @@ export const McpObservability = ({ errorState, routes, pane, onPaneChange, taskU
|
|
|
120
166
|
const engine = useMcpWorkflowsHealth({ enabled: pane === 'live' });
|
|
121
167
|
const live = useTasks({ status: 'working' }, { enabled: pane === 'live' });
|
|
122
168
|
const telemetryNotice = (_jsxs(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: ["This is telemetry: spans and metrics are sampled, kept for a short time and meant for understanding how a call ran. It is not the record of what was allowed \u2014", ' ', _jsx(Text, { as: "span", sx: { color: 'accent.fg', cursor: 'pointer' }, onClick: () => navigate(routes.audit), children: "the audit log" }), ' ', "is, and it is kept per plan and never changed."] }));
|
|
123
|
-
return (_jsxs(Box, { display: "grid", gap: 3, minWidth: 0, children: [showTitle && (_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "Observability" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: "How the calls of your agents ran: where the time went, and how the service is behaving." })] })),
|
|
169
|
+
return (_jsxs(Box, { display: "grid", gap: 3, minWidth: 0, children: [showTitle && (_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "Observability" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: "How the calls of your agents ran: where the time went, and how the service is behaving." })] })), panes.length > 1 && (_jsx(SegmentedControl, { "aria-label": "Observability panes", size: "small", children: panes.map(each => (_jsx(SegmentedControl.Button, { selected: pane === each, onClick: () => onPaneChange(each), children: PANE_LABELS[each] }, each))) })), telemetryNotice, pane === 'runs' && (_jsxs(Box, { display: "grid", gap: 3, children: [_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap", children: [_jsx(TextInput, { size: "small", leadingVisual: SearchIcon, "aria-label": "Task or trace id", placeholder: "Task uid or trace id", value: find, sx: { minWidth: 320 }, onChange: event => setFind(event.currentTarget.value) }), _jsx(Button, { size: "small", onClick: () => {
|
|
124
170
|
const value = find.trim();
|
|
125
171
|
if (!value) {
|
|
126
172
|
onSelect({});
|
|
@@ -131,16 +177,18 @@ export const McpObservability = ({ errorState, routes, pane, onPaneChange, taskU
|
|
|
131
177
|
onSelect(/^[0-9a-f]{32}$/i.test(value)
|
|
132
178
|
? { trace: value }
|
|
133
179
|
: { task: value });
|
|
134
|
-
}, children: "Show the run" })] }), !taskUid && !traceId ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(TelescopeIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "Pick a run" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Open a call from the audit log or the dashboard, or paste its task uid or trace id here, and its spans across the gateway, the policy check and the worker appear with the time each took." }) }), _jsx(Button, { size: "small", onClick: () => navigate(routes.audit), children: "Open the audit log" })] })) : run.isError ? (_jsx(McpErrorBlankslate, { state: errorState(run.error, 'This run'), onRetry: () => run.refetch() })) : run.isPending ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 5, children: _jsx(Spinner, {}) })) : (run.data?.spans.length ?? 0) === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(TelescopeIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "No span for this call" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Spans reach the collector a few seconds after the call and are kept for a short time. A call older than the retention window has an audit row but no trace." }) })] })) : (_jsxs(Box, { display: "grid", gap: 2, children: [_jsxs(Box, { display: "flex", gap: 3, alignItems: "center", flexWrap: "wrap", children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontFamily: 'mono' }, children: run.data?.traceId }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [run.data?.spans.length, " spans"] }), _jsx(Button, { size: "small", onClick: () => navigate(`${routes.audit}?trace=${encodeURIComponent(run.data?.traceId ?? '')}`), children: "The audit row" })] }), _jsx(TraceTimeline, { tree: run.data?.tree ?? [] }), _jsx(Card, { border: true, shadow: "none", display: "block", overflowX: "auto", children: (run.data?.tree ?? []).map(node => (_jsx(SpanRow, { node: node, depth: 0 }, node.span.span_id))) })] }))] })), pane === 'live' && (_jsxs(Box, { display: "grid", gap: 3, children: [(() => {
|
|
135
|
-
const look = engineLook(engine.data, engine.isError);
|
|
136
|
-
const queues = engine.data?.queues ?? [];
|
|
137
|
-
return (_jsxs(Card, { border: true, shadow: "none", p: 3, display: "grid", gap: 2, children: [_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap", children: [_jsx(Heading, { as: "h3", sx: { fontSize: 1 }, children: "The durable engine" }), _jsx(Label, { size: "small", variant: look.variant, children: look.label }), engine.data?.engine && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: engine.data.engine }))] }), look.note && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: look.note })), queues.length > 0 && (_jsx(Box, { display: "grid", gap: 1, children: queues.map(queue => (_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", fontSize: 0, children: [_jsx(Text, { sx: { fontFamily: 'mono' }, children: queue.name }), _jsxs(Text, { sx: { color: 'fg.muted' }, children: [queue.backlog, " waiting"] }), typeof queue.oldestWaitSeconds === 'number' && (_jsxs(Text, { sx: { color: 'fg.muted' }, children: ["oldest", ' ', durationLabel(queue.oldestWaitSeconds * 1000)] }))] }, queue.name))) }))] }));
|
|
138
|
-
})(), _jsxs(Box, { display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 1 }, children: "Running now" }), live.isError ? (_jsx(McpErrorBlankslate, { state: errorState(live.error, 'The running calls'), onRetry: () => live.refetch() })) : live.isPending ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 4, children: _jsx(Spinner, {}) })) : (live.data?.items.length ?? 0) === 0 ? (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Nothing of yours is running. A call that finishes inside its own request never appears here \u2014 it is over before this pane could draw it \u2014 and is found by its trace in Runs." })) : ((live.data?.items ?? []).map(task => (_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap", py: 1, borderBottom: "1px solid", borderColor: "border.muted", children: [_jsx(Label, { size: "small", variant: "success", children: "Working" }), _jsx(Text, { sx: { fontSize: 0 }, children: task.tool || 'Run' }), _jsx(Text, { sx: {
|
|
180
|
+
}, children: "Show the run" })] }), !taskUid && !traceId ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(TelescopeIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "Pick a run" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Open a call from the audit log or the dashboard, or paste its task uid or trace id here, and its spans across the gateway, the policy check and the worker appear with the time each took." }) }), _jsx(Button, { size: "small", onClick: () => navigate(routes.audit), children: "Open the audit log" })] })) : run.isError ? (_jsx(McpErrorBlankslate, { state: errorState(run.error, 'This run'), onRetry: () => run.refetch() })) : run.isPending ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 5, children: _jsx(Spinner, {}) })) : (run.data?.spans.length ?? 0) === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(TelescopeIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "No span for this call" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "Spans reach the collector a few seconds after the call and are kept for a short time. A call older than the retention window has an audit row but no trace." }) })] })) : (_jsxs(Box, { display: "grid", gap: 2, children: [_jsxs(Box, { display: "flex", gap: 3, alignItems: "center", flexWrap: "wrap", children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontFamily: 'mono' }, children: run.data?.traceId }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [run.data?.spans.length, " spans"] }), _jsx(Button, { size: "small", onClick: () => navigate(`${routes.audit}?trace=${encodeURIComponent(run.data?.traceId ?? '')}`), children: "The audit row" })] }), _jsx(TraceTimeline, { tree: run.data?.tree ?? [] }), _jsx(Card, { border: true, shadow: "none", display: "block", overflowX: "auto", children: (run.data?.tree ?? []).map(node => (_jsx(SpanRow, { node: node, depth: 0 }, node.span.span_id))) })] }))] })), pane === 'live' && (_jsxs(Box, { display: "grid", gap: 3, children: [_jsxs(Card, { border: true, shadow: "none", p: 3, display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 1 }, children: "Running now" }), live.isError ? (_jsx(McpErrorBlankslate, { state: errorState(live.error, 'The running calls'), onRetry: () => live.refetch() })) : live.isPending ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 4, children: _jsx(Spinner, {}) })) : (live.data?.items.length ?? 0) === 0 ? (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "Nothing of yours is running. A call that finishes inside its own request never appears here \u2014 it is over before this pane could draw it \u2014 and is found by its trace in Runs." })) : ((live.data?.items ?? []).map(task => (_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap", py: 1, borderBottom: "1px solid", borderColor: "border.muted", children: [_jsx(Label, { size: "small", variant: "success", children: "Working" }), _jsx(Text, { sx: { fontSize: 0 }, children: task.tool || 'Run' }), _jsx(Text, { sx: {
|
|
139
181
|
fontSize: 0,
|
|
140
182
|
fontFamily: 'mono',
|
|
141
183
|
color: 'accent.fg',
|
|
142
184
|
cursor: 'pointer',
|
|
143
|
-
}, onClick: () => onSelect({ task: task.uid }), children: task.uid }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: ["started ", timeAgo(task.createdAt)] })] }, task.uid))))] })
|
|
185
|
+
}, onClick: () => onSelect({ task: task.uid }), children: task.uid }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: ["started ", timeAgo(task.createdAt)] })] }, task.uid))))] }), (() => {
|
|
186
|
+
const look = engineLook(engine.data, engine.isError);
|
|
187
|
+
const queues = engineQueues(engine.data);
|
|
188
|
+
return (_jsxs(Card, { border: true, shadow: "none", p: 3, display: "grid", gap: 2, children: [_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", flexWrap: "wrap", children: [_jsx(Heading, { as: "h3", sx: { fontSize: 1 }, children: "The durable engine" }), _jsx(Label, { size: "small", variant: look.variant, children: look.label }), engine.data?.engine && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: engine.data.engine }))] }), look.note && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: look.note })), queues.length > 0 && (_jsx(Box, { display: "grid", gap: 1, children: queues.map(queue => (_jsxs(Box, { display: "flex", gap: 2, alignItems: "center", fontSize: 0, children: [_jsx(Text, { sx: { fontFamily: 'mono' }, children: queue.name }), queue.served ? (_jsx(Text, { sx: { color: 'fg.muted' }, children: queue.running > 0
|
|
189
|
+
? `${queue.running} running`
|
|
190
|
+
: 'idle' })) : (_jsx(Label, { size: "small", variant: "attention", children: "nobody serves it" })), typeof queue.oldestSeconds === 'number' && (_jsxs(Text, { sx: { color: 'fg.muted' }, children: ["oldest ", durationLabel(queue.oldestSeconds * 1000)] }))] }, queue.name))) }))] }));
|
|
191
|
+
})()] })), pane === 'metrics' && (_jsx(Box, { display: "grid", gap: 3, children: metrics.isError ? (_jsx(McpErrorBlankslate, { state: errorState(metrics.error, 'Metrics'), onRetry: () => metrics.refetch() })) : metrics.isPending && !metrics.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 5, children: _jsx(Spinner, {}) })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { display: "grid", gap: 3, gridTemplateColumns: [
|
|
144
192
|
'1fr',
|
|
145
193
|
'repeat(2, 1fr)',
|
|
146
194
|
'repeat(4, 1fr)',
|
|
@@ -157,7 +157,7 @@ export const Policies = ({ errorState, routes, organizationName, showTitle = tru
|
|
|
157
157
|
}
|
|
158
158
|
return (_jsxs(Box, { display: "grid", gap: 4, minWidth: 0, children: [showTitle && (_jsxs(Box, { children: [_jsx(Heading, { as: "h2", sx: { fontSize: 3, mb: 1 }, children: "Policies" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', fontSize: 1, m: 0 }, children: previewAs
|
|
159
159
|
? 'What applies to this agent, and which layer decided it.'
|
|
160
|
-
: 'What applies to your agents, and which layer decided it.' })] })), (agents.data?.length ?? 0) > 1 && (_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Preview as" }), _jsx(Box, { minWidth: "18rem", children: _jsxs(Select, { value: previewAs, onChange: event => setPreviewAs(event.target.value), "aria-label": "Preview the policy as", children: [_jsx(Select.Option, { value: "", children: "You \u2014 your own token" }), (agents.data ?? []).map(agent => (_jsx(Select.Option, { value: agent.clientId, children: agent.clientName || agent.clientId }, agent.uid)))] }) }), previewAs && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "A grant carries its own scopes and its own admitted clients, so this can differ from what applies to you." }))] })), policy.isPending && !policy.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 6, children: _jsx(Spinner, {}) })) : rows.length === 0 && tools.length === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(ShieldCheckIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "The defaults apply" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "No agent is connected yet, so nothing narrows the Datalayer defaults. Connect a client and this page fills with what its calls are allowed to do." }) }), _jsx(Link, { as: "button", sx: { cursor: 'pointer' }, onClick: () => navigate(routes.
|
|
160
|
+
: 'What applies to your agents, and which layer decided it.' })] })), (agents.data?.length ?? 0) > 1 && (_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", children: [_jsx(Text, { sx: { fontSize: 1, color: 'fg.muted' }, children: "Preview as" }), _jsx(Box, { minWidth: "18rem", children: _jsxs(Select, { value: previewAs, onChange: event => setPreviewAs(event.target.value), "aria-label": "Preview the policy as", children: [_jsx(Select.Option, { value: "", children: "You \u2014 your own token" }), (agents.data ?? []).map(agent => (_jsx(Select.Option, { value: agent.clientId, children: agent.clientName || agent.clientId }, agent.uid)))] }) }), previewAs && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: "A grant carries its own scopes and its own admitted clients, so this can differ from what applies to you." }))] })), policy.isPending && !policy.data ? (_jsx(Box, { display: "flex", justifyContent: "center", py: 6, children: _jsx(Spinner, {}) })) : rows.length === 0 && tools.length === 0 ? (_jsxs(Blankslate, { border: true, spacious: true, children: [_jsx(Blankslate.Visual, { children: _jsx(ShieldCheckIcon, { size: "medium" }) }), _jsx(Blankslate.Heading, { children: "The defaults apply" }), _jsx(Blankslate.Description, { children: _jsx(Text, { sx: { textAlign: 'center' }, children: "No agent is connected yet, so nothing narrows the Datalayer defaults. Connect a client and this page fills with what its calls are allowed to do." }) }), _jsx(Link, { as: "button", sx: { cursor: 'pointer' }, onClick: () => navigate(routes.configuration), children: "Set up a client" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { display: "grid", gap: 2, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2 }, children: "What applies to your agents" }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: ["Scope of this reading: ", policy.data?.scope ?? 'personal', policy.data?.evaluatedAt
|
|
161
161
|
? ` · evaluated ${policy.data.evaluatedAt}`
|
|
162
162
|
: ''] })] }), _jsxs(Table.Container, { children: [_jsx(Table.Title, { as: "h4", id: "effective-policy", children: "Effective policy" }), _jsx(Table.Subtitle, { as: "p", id: "effective-policy-subtitle", children: "Each layer only narrows the one above it, so the strictest rule is what applies." }), _jsxs(Box, { as: "table", "aria-labelledby": "effective-policy", "aria-describedby": "effective-policy-subtitle", width: "100%", borderCollapse: "collapse", children: [_jsx(Box, { as: "thead", children: _jsx(Box, { as: "tr", children: ['Rule', 'What applies', 'Decided by'].map(header => (_jsx(Box, { as: "th", textAlign: "left", fontSize: 0, color: "fg.muted", fontWeight: "semibold", py: 2, px: 2, borderBottom: "1px solid", borderColor: "border.default", children: header }, header))) }) }), _jsx(Box, { as: "tbody", children: rows.map(row => (_jsxs(Box, { as: "tr", children: [_jsxs(Box, { as: "td", py: 2, px: 2, borderBottom: "1px solid", borderColor: "border.muted", verticalAlign: "top", children: [_jsx(Text, { sx: { fontSize: 1, display: 'block' }, children: row.label }), row.description && (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: row.description }))] }), _jsx(Box, { as: "td", py: 2, px: 2, borderBottom: "1px solid", borderColor: "border.muted", verticalAlign: "top", children: _jsx(Text, { sx: { fontSize: 1 }, children: ruleValueLabel(row.rule.value) }) }), _jsxs(Box, { as: "td", py: 2, px: 2, borderBottom: "1px solid", borderColor: "border.muted", verticalAlign: "top", whiteSpace: "nowrap", children: [_jsx(Label, { size: "small", variant: LAYER_VARIANT[row.rule.decidedBy], children: layerLabel(row.rule.decidedBy, organizationName) }), row.rule.reason && (_jsx(Text, { sx: {
|
|
163
163
|
fontSize: 0,
|