@datalayer/core 1.2.27 → 1.2.28

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 (46) hide show
  1. package/lib/api/evals/__tests__/external-definition.golden.json +110 -0
  2. package/lib/api/evals/client.d.ts +13 -1
  3. package/lib/api/evals/client.js +13 -0
  4. package/lib/api/evals/types.d.ts +168 -1
  5. package/lib/api/mcp/agentAccess.d.ts +10 -0
  6. package/lib/api/mcp/agentAccess.js +28 -0
  7. package/lib/api/mcp/generated.js +20 -0
  8. package/lib/api/mcp/index.d.ts +1 -0
  9. package/lib/api/mcp/index.js +1 -0
  10. package/lib/components/index.d.ts +1 -0
  11. package/lib/components/index.js +1 -0
  12. package/lib/components/mcp/ScopeList.js +10 -1
  13. package/lib/components/toast/ToastPanel.d.ts +11 -0
  14. package/lib/components/toast/ToastPanel.js +31 -0
  15. package/lib/components/toast/index.d.ts +2 -0
  16. package/lib/components/toast/index.js +6 -0
  17. package/lib/components/toast/toastQueue.d.ts +33 -0
  18. package/lib/components/toast/toastQueue.js +47 -0
  19. package/lib/hooks/useDatalayer.js +12 -16
  20. package/lib/hooks/useToast.d.ts +6 -0
  21. package/lib/hooks/useToast.js +40 -1
  22. package/lib/models/McpAgentAccess.d.ts +95 -0
  23. package/lib/models/McpAgentAccess.js +5 -0
  24. package/lib/models/index.d.ts +1 -0
  25. package/lib/models/index.js +1 -0
  26. package/lib/otel/views/OtelLive.js +2 -2
  27. package/lib/otel/views/OtelLiveSpans.js +2 -2
  28. package/lib/otel/views/OtelLogsList.js +3 -3
  29. package/lib/otel/views/OtelMetricsChart.d.ts +13 -0
  30. package/lib/otel/views/OtelMetricsChart.js +109 -26
  31. package/lib/otel/views/OtelSpanDetail.js +3 -3
  32. package/lib/otel/views/OtelSqlView.js +4 -4
  33. package/lib/otel/views/OtelSystemView.js +2 -2
  34. package/lib/otel/views/OtelTimelineRangeSlider.js +2 -2
  35. package/lib/otel/views/OtelTracesList.js +2 -2
  36. package/lib/views/mcp/AuditLog.js +1 -1
  37. package/lib/views/mcp/ConnectedAgents.d.ts +1 -1
  38. package/lib/views/mcp/ConnectedAgents.js +13 -10
  39. package/lib/views/mcp/McpDashboard.d.ts +1 -1
  40. package/lib/views/mcp/McpDashboard.js +6 -6
  41. package/lib/views/mcp/McpHome.d.ts +3 -1
  42. package/lib/views/mcp/McpHome.js +19 -9
  43. package/lib/views/mcp/Policies.js +1 -1
  44. package/lib/views/mcp/types.d.ts +8 -4
  45. package/lib/views/otel/OtelHeader.js +2 -2
  46. package/package.json +2 -2
@@ -0,0 +1,47 @@
1
+ /*
2
+ * Copyright (c) 2023-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ let toasts = [];
6
+ const listeners = new Set();
7
+ let next = 0;
8
+ const emit = () => {
9
+ for (const listener of listeners) {
10
+ listener();
11
+ }
12
+ };
13
+ /** Info and success go on their own after five seconds; a warning or an error stays. */
14
+ export const defaultAutoClose = (variant) => variant === 'info' || variant === 'success' ? 5000 : false;
15
+ export const toastQueue = {
16
+ subscribe(listener) {
17
+ listeners.add(listener);
18
+ return () => {
19
+ listeners.delete(listener);
20
+ };
21
+ },
22
+ snapshot() {
23
+ return toasts;
24
+ },
25
+ push(toast) {
26
+ // The same toast already waiting is not said twice: a page that asks
27
+ // twice, or two requests that fail alike, show it once.
28
+ const same = toasts.find(queued => queued.variant === toast.variant && queued.message === toast.message);
29
+ if (same) {
30
+ return same.id;
31
+ }
32
+ next += 1;
33
+ const id = `toast-${next}`;
34
+ toasts = [...toasts, { ...toast, id }];
35
+ emit();
36
+ return id;
37
+ },
38
+ update(id, patch) {
39
+ toasts = toasts.map(toast => toast.id === id ? { ...toast, ...patch } : toast);
40
+ emit();
41
+ },
42
+ /** Take one toast away, or every toast without an id. */
43
+ dismiss(id) {
44
+ toasts = id === undefined ? [] : toasts.filter(toast => toast.id !== id);
45
+ emit();
46
+ },
47
+ };
@@ -67,23 +67,19 @@ export function useDatalayer(props = {}) {
67
67
  }
68
68
  else {
69
69
  if (notifyOnError_ && !isForbidden) {
70
- if (responseError.warnings) {
71
- responseError.warnings.forEach(warning => enqueueToast(`${warning}`, { variant: 'warning' }));
72
- }
73
- if (responseError.errors) {
74
- responseError.errors.forEach(error => enqueueToast(`${error}`, { variant: 'error' }));
75
- }
76
- if (responseError.message) {
77
- enqueueToast(`${responseError.message}`, { variant: 'error' });
78
- }
79
- else {
80
- enqueueToast(`API Error`, { variant: 'error' });
81
- }
82
- if (responseError.exceptionMessage) {
83
- enqueueToast(`${responseError.exceptionMessage}`, {
84
- variant: 'error',
85
- });
70
+ // One toast per failed request, its lines together and each once
71
+ // (2 November 2026: its message and its exception were a toast
72
+ // each, so a page that asked twice showed four).
73
+ const once = (lines) => Array.from(new Set(lines.map(line => `${line ?? ''}`.trim()).filter(Boolean))).join('\n');
74
+ const warnings = once(responseError.warnings ?? []);
75
+ if (warnings) {
76
+ enqueueToast(warnings, { variant: 'warning' });
86
77
  }
78
+ enqueueToast(once([
79
+ ...(responseError.errors ?? []),
80
+ responseError.message || 'API Error',
81
+ responseError.exceptionMessage,
82
+ ]), { variant: 'error' });
87
83
  }
88
84
  }
89
85
  const response = {
@@ -14,6 +14,12 @@ export type ToastProps = {
14
14
  */
15
15
  variant: 'info' | 'success' | 'warning' | 'error';
16
16
  };
17
+ /**
18
+ * Toasts in JupyterLab, as they always were: its notifications, or
19
+ * react-toastify until they are loaded. Everywhere else (the SaaS, the
20
+ * desktop…) they go to `ToastPanel`, primer-addons' sliding panel, which the
21
+ * application mounts once (2 November 2026).
22
+ */
17
23
  export declare const useToast: () => Readonly<{
18
24
  /**
19
25
  * Emit a message as a toast.
@@ -3,6 +3,7 @@ import React from 'react';
3
3
  import { toast } from 'react-toastify';
4
4
  import { Button } from '@primer/react';
5
5
  import { isInsideJupyterLab } from '../utils/index.js';
6
+ import { defaultAutoClose, toastQueue } from '../components/toast/toastQueue.js';
6
7
  // `@datalayer/jupyter-react` and `@jupyterlab/apputils` are heavy (they pull
7
8
  // JupyterLab / CodeMirror / lumino) and are only needed when running INSIDE
8
9
  // JupyterLab. They are lazily loaded here so the standalone bundle (landing app)
@@ -76,11 +77,25 @@ function createContent(message, closeHandler, actions) {
76
77
  return (_jsx(React.Suspense, { fallback: null, children: _jsx(JupyterReactTheme, { children: _jsx(ToastButton, { action: action, closeToast: closeHandler }) }) }, 'button-' + idx));
77
78
  })] }))] }));
78
79
  }
80
+ /**
81
+ * Toasts in JupyterLab, as they always were: its notifications, or
82
+ * react-toastify until they are loaded. Everywhere else (the SaaS, the
83
+ * desktop…) they go to `ToastPanel`, primer-addons' sliding panel, which the
84
+ * application mounts once (2 November 2026).
85
+ */
79
86
  export const useToast = () => {
80
87
  // This CANNOT use Jupyter React hooks as it may be used outside Jupyter React context.
81
88
  const insideJupyterLab = isInsideJupyterLab();
82
89
  const enqueueToast = (message, options = { variant: 'info' }) => {
83
90
  const { actions, autoClose } = options;
91
+ if (!insideJupyterLab) {
92
+ return toastQueue.push({
93
+ variant: options.variant,
94
+ message,
95
+ actions,
96
+ autoClose: autoClose ?? defaultAutoClose(options.variant),
97
+ });
98
+ }
84
99
  const theme = resolveToastTheme();
85
100
  switch (options.variant) {
86
101
  case 'info': {
@@ -138,6 +153,27 @@ export const useToast = () => {
138
153
  }
139
154
  };
140
155
  const trackAsyncTask = (promise, options) => {
156
+ if (!insideJupyterLab) {
157
+ // One toast for the task: pending, then what it came to.
158
+ const message = (value, result) => typeof value === 'function'
159
+ ? String(value(result))
160
+ : String(value);
161
+ const id = toastQueue.push({
162
+ variant: 'info',
163
+ message: message(options.pending.message, undefined),
164
+ autoClose: false,
165
+ });
166
+ promise.then(result => toastQueue.update(id, {
167
+ variant: 'success',
168
+ message: message(options.success.message, result),
169
+ autoClose: defaultAutoClose('success'),
170
+ }), reason => toastQueue.update(id, {
171
+ variant: 'error',
172
+ message: message(options.error.message, reason),
173
+ autoClose: defaultAutoClose('error'),
174
+ }));
175
+ return id;
176
+ }
141
177
  const theme = resolveToastTheme();
142
178
  return insideJupyterLab && NotificationApi
143
179
  ? NotificationApi.promise(promise, options)
@@ -169,7 +205,10 @@ export const useToast = () => {
169
205
  * @param id Toast id
170
206
  */
171
207
  dismiss: (id) => {
172
- if (insideJupyterLab && NotificationApi) {
208
+ if (!insideJupyterLab) {
209
+ toastQueue.dismiss(id === undefined ? undefined : String(id));
210
+ }
211
+ else if (insideJupyterLab && NotificationApi) {
173
212
  NotificationApi.dismiss(id);
174
213
  }
175
214
  else {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * What one agent may execute and reach, as the Jupyter MCP Server decides it.
3
+ *
4
+ * Every answer here comes from the steps a call goes through — the token's
5
+ * scopes, the policy layers, then Spacer or Contents for the object — so a
6
+ * tool or a notebook listed as refused is one the agent would be refused.
7
+ *
8
+ * @module models/McpAgentAccess
9
+ */
10
+ /** The agent an answer is about. `grant` is null for the token that asked. */
11
+ export interface McpAgentNamed {
12
+ grant: string | null;
13
+ clientId: string | null;
14
+ clientName: string | null;
15
+ scopes: string[];
16
+ orgUid: string | null;
17
+ teamUid: string | null;
18
+ }
19
+ export type McpToolsetHealthState = 'ready' | 'degraded' | 'unknown';
20
+ export interface McpToolsetHealth {
21
+ /** `unknown` is a toolset nothing here measures, never one reported well. */
22
+ state: McpToolsetHealthState;
23
+ detail: string;
24
+ }
25
+ /**
26
+ * How a client comes to hold a toolset: the plain endpoint serves `base`, a
27
+ * URL names an `optional` one, a `source` is enabled in a session, and an
28
+ * `application` one is reached through an application's connection.
29
+ */
30
+ export type McpToolsetSelected = 'base' | 'optional' | 'source' | 'application';
31
+ /** One tool, decided for this agent. */
32
+ export interface McpAgentTool {
33
+ name: string;
34
+ /** The scopes it requires, space-separated. */
35
+ scope: string;
36
+ allowed: boolean;
37
+ /** Why not: `scope`, a policy rule, `connection` or `item`. */
38
+ rule: string | null;
39
+ /** Which layer decided. */
40
+ decidedBy: string | null;
41
+ detail: string | null;
42
+ /** The layer that makes a person approve a call first, when one does. */
43
+ approval: string | null;
44
+ }
45
+ /** A source's own rule for its calls, as Contents keeps it. */
46
+ export type McpSourceApprovalPolicy = 'explicit' | 'auto-allowlisted' | 'never';
47
+ export interface McpAgentToolset {
48
+ kind: 'toolset' | 'source';
49
+ /** The source, when it is one. */
50
+ uid?: string;
51
+ name: string;
52
+ title: string;
53
+ description: string | null;
54
+ selected: McpToolsetSelected;
55
+ health: McpToolsetHealth;
56
+ /** A source's; a toolset's tools each say their own `approval`. */
57
+ approvalPolicy: McpSourceApprovalPolicy | null;
58
+ tools: McpAgentTool[];
59
+ }
60
+ export interface McpAgentToolsets {
61
+ agent: McpAgentNamed;
62
+ toolsets: McpAgentToolset[];
63
+ /** Why the sources are missing, when Contents could not be read. */
64
+ sourcesError: string | null;
65
+ }
66
+ /** One notebook or source, and how far this agent reaches it. */
67
+ export interface McpReachRow {
68
+ kind: 'notebook' | 'source';
69
+ uid: string;
70
+ name: string;
71
+ /** A notebook's Space, a source's kind. */
72
+ where: string | null;
73
+ read: boolean;
74
+ write: boolean;
75
+ execute: boolean;
76
+ /** Why it stops short of `execute`, in sentences. */
77
+ refused: string[];
78
+ /** Whether a source is the caller's own. */
79
+ yours?: boolean;
80
+ /** Set when the check could not run: an answer about the check, not the agent. */
81
+ unchecked: string | null;
82
+ }
83
+ export interface McpAgentReach {
84
+ agent: McpAgentNamed;
85
+ notebooks: McpReachRow[];
86
+ sources: McpReachRow[];
87
+ /** How many there are of each, of which `limit` were asked about. */
88
+ notebooksListed: number;
89
+ sourcesListed: number;
90
+ limit: number;
91
+ /** Contents has more than were read. */
92
+ sourcesTruncated: boolean;
93
+ notebooksError: string | null;
94
+ sourcesError: string | null;
95
+ }
@@ -0,0 +1,5 @@
1
+ /*
2
+ * Copyright (c) 2023-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ export {};
@@ -51,6 +51,7 @@ export * from './Invite';
51
51
  export * from './LandingRoles';
52
52
  export * from './McpAuditEvent';
53
53
  export * from './McpBinding';
54
+ export * from './McpAgentAccess';
54
55
  export * from './McpPolicy';
55
56
  export * from './McpTask';
56
57
  export * from './Member';
@@ -55,6 +55,7 @@ export * from './Invite.js';
55
55
  export * from './LandingRoles.js';
56
56
  export * from './McpAuditEvent.js';
57
57
  export * from './McpBinding.js';
58
+ export * from './McpAgentAccess.js';
58
59
  export * from './McpPolicy.js';
59
60
  export * from './McpTask.js';
60
61
  export * from './Member.js';
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
13
13
  * @module otel/OtelLive
14
14
  */
15
15
  import { useState, useCallback, useMemo, useEffect, useRef, } from 'react';
16
- import { Box } from '@datalayer/primer-addons';
16
+ import { Box, Card } from '@datalayer/primer-addons';
17
17
  import { Text, Button, Label } from '@primer/react';
18
18
  import { GitBranchIcon, ClockIcon } from '@primer/octicons-react';
19
19
  import { getOtelConsumeUrl } from '../../state/substates/CoreState.js';
@@ -288,7 +288,7 @@ export const OtelLive = ({ baseUrl = getOtelConsumeUrl(), wsBaseUrl, token, auto
288
288
  return ts >= s && ts <= e;
289
289
  });
290
290
  }, [metrics, isRangeActive, rangeStart, rangeEnd]);
291
- return (_jsxs(Box, { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, height: "100%", color: "fg.default", bg: "canvas.default", border: "1px solid", borderColor: "border.default", borderRadius: 2, overflow: "hidden", children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 0, children: [_jsx(Box, { flex: 1, children: _jsx(OtelSearchBar, { signal: signal, onSignalChange: setSignal, services: effectiveServices, selectedService: service, onServiceChange: setService, query: query, onQueryChange: setQuery, onRefresh: handleRefresh, loading: signal === 'traces'
291
+ return (_jsxs(Card, { border: true, shadow: "none", display: "flex", flexDirection: "column", flex: 1, minHeight: 0, height: "100%", color: "fg.default", overflow: "hidden", children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 0, children: [_jsx(Box, { flex: 1, children: _jsx(OtelSearchBar, { signal: signal, onSignalChange: setSignal, services: effectiveServices, selectedService: service, onServiceChange: setService, query: query, onQueryChange: setQuery, onRefresh: handleRefresh, loading: signal === 'traces'
292
292
  ? tracesLoading
293
293
  : signal === 'logs'
294
294
  ? logsLoading
@@ -16,7 +16,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
16
16
  * @module otel/views/OtelLiveSpans
17
17
  */
18
18
  import { useMemo, useState } from 'react';
19
- import { Box } from '@datalayer/primer-addons';
19
+ import { Box, Card } from '@datalayer/primer-addons';
20
20
  import { ActionList, ActionMenu, Button, Text } from '@primer/react';
21
21
  import { DownloadIcon, PauseIcon, PlayIcon, TrashIcon, } from '@primer/octicons-react';
22
22
  import { toMs } from '../utils.js';
@@ -94,7 +94,7 @@ export const OtelLiveSpans = ({ tracer, spans: given, service, facets = DEFAULT_
94
94
  ? everything.filter(span => span.trace_id === selectedSpan.trace_id)
95
95
  : [], [everything, selectedSpan]);
96
96
  const side = detailPlacement === 'side' && !compact;
97
- return (_jsxs(Box, { "data-otel-live-spans": "", border: "1px solid", borderColor: "border.default", borderRadius: 2, bg: "canvas.default", color: "fg.default", minWidth: 0, children: [_jsxs(Box, { role: "toolbar", "aria-label": label, display: "flex", flexWrap: "wrap", gap: 2, alignItems: "center", p: 2, borderBottom: "1px solid", borderColor: "border.default", children: [facets.map((facet, index) => (_jsx(FacetMenu, { facet: facet, options: options[index] ?? [], selected: selected[index] ?? new Set(), onChange: next => setSelected(prev => facets.map((_, at) => at === index ? next : (prev[at] ?? new Set()))) }, facet.label))), _jsx(Box, { flex: 1 }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, "data-otel-span-count": "", children: [rows.length === shown.length
97
+ return (_jsxs(Card, { border: true, shadow: "none", display: "block", "data-otel-live-spans": "", color: "fg.default", minWidth: 0, children: [_jsxs(Box, { role: "toolbar", "aria-label": label, display: "flex", flexWrap: "wrap", gap: 2, alignItems: "center", p: 2, borderBottom: "1px solid", borderColor: "border.default", children: [facets.map((facet, index) => (_jsx(FacetMenu, { facet: facet, options: options[index] ?? [], selected: selected[index] ?? new Set(), onChange: next => setSelected(prev => facets.map((_, at) => at === index ? next : (prev[at] ?? new Set()))) }, facet.label))), _jsx(Box, { flex: 1 }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, "data-otel-span-count": "", children: [rows.length === shown.length
98
98
  ? `${rows.length} ${rows.length === 1 ? 'span' : 'spans'}`
99
99
  : `${rows.length} of ${shown.length}`, waiting > 0 ? ` · ${waiting} new` : ''] }), _jsx(Button, { size: "small", leadingVisual: held ? PlayIcon : PauseIcon, "aria-pressed": held !== null, onClick: () => setHeld(held ? null : recorded), "data-otel-pause": "", children: held ? 'Resume' : 'Pause' }), _jsx(Button, { size: "small", leadingVisual: TrashIcon, onClick: () => {
100
100
  setHeld(null);
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * @module otel/OtelLogsList
13
13
  */
14
14
  import React, { useState } from 'react';
15
- import { Box } from '@datalayer/primer-addons';
15
+ import { Box, Card } from '@datalayer/primer-addons';
16
16
  import { Text, Label, Spinner } from '@primer/react';
17
17
  import { Blankslate } from '@primer/react/experimental';
18
18
  import { LogIcon } from '@primer/octicons-react';
@@ -27,13 +27,13 @@ const LogDetail = ({ log }) => (_jsxs(Box, { gridColumn: "1 / -1", bg: "canvas.s
27
27
  color: 'fg.muted',
28
28
  display: 'block',
29
29
  mb: 1,
30
- }, children: "Body" }), _jsx(Box, { as: "pre", fontSize: 1, fontFamily: "mono", whiteSpace: "pre-wrap", wordBreak: "break-word", m: 0, bg: "canvas.default", border: "1px solid", borderColor: "border.default", borderRadius: 2, p: 2, children: log.body })] }), log.trace_id && (_jsxs(Box, { display: "flex", gap: 3, mb: 2, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontWeight: 'bold' }, children: "trace_id" }), _jsx(Text, { sx: { fontFamily: 'mono', fontSize: 0 }, children: log.trace_id }), log.span_id && (_jsxs(_Fragment, { children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontWeight: 'bold' }, children: "span_id" }), _jsx(Text, { sx: { fontFamily: 'mono', fontSize: 0 }, children: log.span_id })] }))] })), log.attributes && Object.keys(log.attributes).length > 0 && (_jsxs(Box, { children: [_jsx(Text, { sx: {
30
+ }, children: "Body" }), _jsx(Card, { border: true, shadow: "none", display: "block", as: "pre", fontSize: 1, fontFamily: "mono", whiteSpace: "pre-wrap", wordBreak: "break-word", m: 0, p: 2, children: log.body })] }), log.trace_id && (_jsxs(Box, { display: "flex", gap: 3, mb: 2, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontWeight: 'bold' }, children: "trace_id" }), _jsx(Text, { sx: { fontFamily: 'mono', fontSize: 0 }, children: log.trace_id }), log.span_id && (_jsxs(_Fragment, { children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontWeight: 'bold' }, children: "span_id" }), _jsx(Text, { sx: { fontFamily: 'mono', fontSize: 0 }, children: log.span_id })] }))] })), log.attributes && Object.keys(log.attributes).length > 0 && (_jsxs(Box, { children: [_jsx(Text, { sx: {
31
31
  fontSize: 0,
32
32
  fontWeight: 'bold',
33
33
  color: 'fg.muted',
34
34
  display: 'block',
35
35
  mb: 1,
36
- }, children: "Attributes" }), _jsx(Box, { bg: "canvas.default", border: "1px solid", borderColor: "border.default", borderRadius: 2, p: 2, children: Object.entries(log.attributes).map(([k, v]) => (_jsxs(Box, { display: "flex", gap: 2, py: 1, borderBottom: "1px solid", borderColor: "border.muted", children: [_jsx(Text, { sx: {
36
+ }, children: "Attributes" }), _jsx(Card, { border: true, shadow: "none", display: "block", p: 2, children: Object.entries(log.attributes).map(([k, v]) => (_jsxs(Box, { display: "flex", gap: 2, py: 1, borderBottom: "1px solid", borderColor: "border.muted", children: [_jsx(Text, { sx: {
37
37
  color: 'accent.fg',
38
38
  fontSize: 0,
39
39
  fontFamily: 'mono',
@@ -13,6 +13,19 @@
13
13
  */
14
14
  import React from 'react';
15
15
  import type { OtelMetric } from '../types';
16
+ /**
17
+ * The colours ECharts draws its text, axes, grid lines and tooltip with.
18
+ * A canvas reads no CSS variable, so they are read off a probe the theme
19
+ * colours (fg.default, fg.muted, border.muted, canvas.overlay), resolved to
20
+ * what the colour mode makes them: hard-coded, the axes and the tooltip were
21
+ * light-mode only and unreadable in the dark (28 October 2026).
22
+ */
23
+ export interface ChartInk {
24
+ text: string;
25
+ muted: string;
26
+ line: string;
27
+ surface: string;
28
+ }
16
29
  export interface OtelMetricsChartProps {
17
30
  metrics: OtelMetric[];
18
31
  /** Height per chart panel in px. Default 240. */
@@ -1,4 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2023-2025 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * OtelMetricsChart – Type-aware ECharts visualisation for OTEL metrics.
8
+ *
9
+ * Renders one chart section per `metric_type` (`sum`, `histogram`, `gauge`,
10
+ * and any other type present) with an appropriate chart style:
11
+ *
12
+ * - **sum** (counters): area-line chart with gradient fill
13
+ * - **histogram**: bar chart with grouped series
14
+ * - **gauge**: plain line chart (no area fill)
15
+ * - **other**: falls back to area-line
16
+ *
17
+ * @module otel/OtelMetricsChart
18
+ */
19
+ import React, { useLayoutEffect, useRef, useState } from 'react';
2
20
  import * as echarts from 'echarts';
3
21
  import ReactECharts from 'echarts-for-react';
4
22
  import { Box } from '@datalayer/primer-addons';
@@ -108,30 +126,90 @@ const TYPE_ORDER = {
108
126
  gauge: 2,
109
127
  exponentialHistogram: 3,
110
128
  };
129
+ /** Light-mode values, until the probe has been read. */
130
+ const DEFAULT_INK = {
131
+ text: '#1f2328',
132
+ muted: '#59636e',
133
+ line: '#d1d9e0',
134
+ surface: '#ffffff',
135
+ };
136
+ function useChartInk() {
137
+ const probe = useRef(null);
138
+ const [ink, setInk] = useState(DEFAULT_INK);
139
+ useLayoutEffect(() => {
140
+ const node = probe.current;
141
+ if (!node || typeof getComputedStyle === 'undefined') {
142
+ return;
143
+ }
144
+ const read = () => {
145
+ const style = getComputedStyle(node);
146
+ const muted = node.firstElementChild
147
+ ? getComputedStyle(node.firstElementChild).color
148
+ : style.color;
149
+ const next = {
150
+ text: style.color || DEFAULT_INK.text,
151
+ muted: muted || DEFAULT_INK.muted,
152
+ line: style.borderTopColor || DEFAULT_INK.line,
153
+ surface: style.backgroundColor || DEFAULT_INK.surface,
154
+ };
155
+ setInk(current => current.text === next.text &&
156
+ current.muted === next.muted &&
157
+ current.line === next.line &&
158
+ current.surface === next.surface
159
+ ? current
160
+ : next);
161
+ };
162
+ read();
163
+ // The colour mode changes on the document's attributes.
164
+ const observer = new MutationObserver(read);
165
+ observer.observe(document.documentElement, {
166
+ attributes: true,
167
+ subtree: false,
168
+ });
169
+ observer.observe(document.body, { attributes: true, subtree: false });
170
+ return () => observer.disconnect();
171
+ });
172
+ return [probe, ink];
173
+ }
174
+ /** The probe the ink is read off: drawn, never seen. */
175
+ const InkProbe = React.forwardRef((_, ref) => (_jsx(Box, { ref: ref, "aria-hidden": true, color: "fg.default", bg: "canvas.overlay", borderTop: "1px solid", borderColor: "border.muted", sx: {
176
+ position: 'absolute',
177
+ width: 0,
178
+ height: 0,
179
+ overflow: 'hidden',
180
+ pointerEvents: 'none',
181
+ }, children: _jsx(Box, { as: "span", color: "fg.muted" }) })));
182
+ InkProbe.displayName = 'InkProbe';
111
183
  // ── Build ECharts option per type ──────────────────────────────────
112
- function buildOption(mtype, nameMap) {
184
+ function buildOption(mtype, nameMap, ink = DEFAULT_INK) {
113
185
  const names = [...nameMap.keys()];
114
186
  const baseLegend = {
115
187
  data: names,
116
188
  top: 6,
117
- textStyle: { fontSize: 11 },
189
+ textStyle: { fontSize: 11, color: ink.muted },
118
190
  };
119
191
  const baseGrid = { left: 50, right: 20, top: 40, bottom: 30 };
120
192
  const baseTooltip = {
121
193
  trigger: 'axis',
122
- axisPointer: { type: 'cross', label: { backgroundColor: '#6a7985' } },
194
+ axisPointer: {
195
+ type: 'cross',
196
+ label: { backgroundColor: ink.muted, color: ink.surface },
197
+ },
198
+ backgroundColor: ink.surface,
199
+ borderColor: ink.line,
200
+ textStyle: { color: ink.text },
123
201
  };
124
202
  const baseXAxis = {
125
203
  type: 'time',
126
204
  boundaryGap: false,
127
- axisLine: { lineStyle: { color: '#ccc' } },
128
- axisLabel: { fontSize: 10 },
205
+ axisLine: { lineStyle: { color: ink.line } },
206
+ axisLabel: { fontSize: 10, color: ink.muted },
129
207
  };
130
208
  const baseYAxis = {
131
209
  type: 'value',
132
- splitLine: { lineStyle: { type: 'dashed', color: '#e8e8e8' } },
210
+ splitLine: { lineStyle: { type: 'dashed', color: ink.line } },
133
211
  axisLine: { show: false },
134
- axisLabel: { fontSize: 10 },
212
+ axisLabel: { fontSize: 10, color: ink.muted },
135
213
  };
136
214
  if (mtype === 'histogram' || mtype === 'exponentialHistogram') {
137
215
  // ── Bar chart ──
@@ -257,29 +335,34 @@ function formatDuration(ms) {
257
335
  }
258
336
  // ── Component ───────────────────────────────────────────────────────
259
337
  export const OtelMetricsChart = ({ metrics, height = 240, }) => {
338
+ const [probe, ink] = useChartInk();
260
339
  if (!metrics || metrics.length === 0) {
261
- return (_jsx(ReactECharts, { echarts: echarts, option: {
262
- title: {
263
- text: 'No metrics data',
264
- left: 'center',
265
- top: 'center',
266
- textStyle: { color: '#999', fontSize: 14, fontWeight: 'normal' },
267
- },
268
- }, style: { width: '100%', height: `${height}px` }, notMerge: true }));
340
+ return (_jsxs(Box, { position: "relative", children: [_jsx(InkProbe, { ref: probe }), _jsx(ReactECharts, { echarts: echarts, option: {
341
+ title: {
342
+ text: 'No metrics data',
343
+ left: 'center',
344
+ top: 'center',
345
+ textStyle: {
346
+ color: ink.muted,
347
+ fontSize: 14,
348
+ fontWeight: 'normal',
349
+ },
350
+ },
351
+ }, style: { width: '100%', height: `${height}px` }, notMerge: true })] }));
269
352
  }
270
353
  const byType = groupByType(metrics);
271
354
  // Sort types in a stable, user-friendly order.
272
355
  const sortedTypes = [...byType.keys()].sort((a, b) => (TYPE_ORDER[a] ?? 99) - (TYPE_ORDER[b] ?? 99));
273
- return (_jsx(Box, { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, children: sortedTypes.map(mtype => {
274
- const nameMap = byType.get(mtype);
275
- const option = buildOption(mtype, nameMap);
276
- const stats = computeStats(nameMap);
277
- const durationMs = stats.startTs !== null && stats.endTs !== null
278
- ? stats.endTs - stats.startTs
279
- : null;
280
- return (_jsxs(Box, { children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, px: 1, mb: 1, children: [_jsx(Label, { size: "small", variant: typeVariant(mtype), children: mtype }), _jsx(Text, { sx: { fontSize: 1, fontWeight: 'bold', color: 'fg.muted' }, children: typeTitle(mtype) }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: ["(", [...nameMap.keys()].join(', '), ")"] })] }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 3, px: 1, mb: 1, children: [_jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Sum:" }), ' ', stats.total.toLocaleString(undefined, {
281
- maximumFractionDigits: 4,
282
- })] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Points:" }), ' ', stats.count] }), stats.startTs !== null && stats.endTs !== null && (_jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Interval:" }), ' ', formatTs(stats.startTs), " \u2192 ", formatTs(stats.endTs), durationMs !== null && durationMs > 0 && (_jsxs(Text, { as: "span", sx: { color: 'fg.subtle' }, children: [' ', "(", formatDuration(durationMs), ")"] }))] }))] }), _jsx(ReactECharts, { echarts: echarts, option: option, style: { width: '100%', height: `${height}px` }, notMerge: true })] }, mtype));
283
- }) }));
356
+ return (_jsxs(Box, { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2, position: "relative", children: [_jsx(InkProbe, { ref: probe }), sortedTypes.map(mtype => {
357
+ const nameMap = byType.get(mtype);
358
+ const option = buildOption(mtype, nameMap, ink);
359
+ const stats = computeStats(nameMap);
360
+ const durationMs = stats.startTs !== null && stats.endTs !== null
361
+ ? stats.endTs - stats.startTs
362
+ : null;
363
+ return (_jsxs(Box, { children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 2, px: 1, mb: 1, children: [_jsx(Label, { size: "small", variant: typeVariant(mtype), children: mtype }), _jsx(Text, { sx: { fontSize: 1, fontWeight: 'bold', color: 'fg.muted' }, children: typeTitle(mtype) }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.subtle' }, children: ["(", [...nameMap.keys()].join(', '), ")"] })] }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 3, px: 1, mb: 1, children: [_jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Sum:" }), ' ', stats.total.toLocaleString(undefined, {
364
+ maximumFractionDigits: 4,
365
+ })] }), _jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Points:" }), ' ', stats.count] }), stats.startTs !== null && stats.endTs !== null && (_jsxs(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: [_jsx(Text, { as: "span", sx: { fontWeight: 'bold', color: 'fg.default' }, children: "Interval:" }), ' ', formatTs(stats.startTs), " \u2192 ", formatTs(stats.endTs), durationMs !== null && durationMs > 0 && (_jsxs(Text, { as: "span", sx: { color: 'fg.subtle' }, children: [' ', "(", formatDuration(durationMs), ")"] }))] }))] }), _jsx(ReactECharts, { echarts: echarts, option: option, style: { width: '100%', height: `${height}px` }, notMerge: true })] }, mtype));
366
+ })] }));
284
367
  };
285
368
  export default OtelMetricsChart;
@@ -12,7 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  * @module otel/OtelSpanDetail
13
13
  */
14
14
  import { useMemo, useState } from 'react';
15
- import { Box } from '@datalayer/primer-addons';
15
+ import { Box, Card } from '@datalayer/primer-addons';
16
16
  import { Text, IconButton, UnderlineNav, CounterLabel, Label, } from '@primer/react';
17
17
  import { XIcon, ChevronDownIcon, ChevronRightIcon, } from '@primer/octicons-react';
18
18
  import { formatDuration, buildSpanTree } from '../utils.js';
@@ -39,7 +39,7 @@ const CollapsibleSection = ({ title, data, defaultOpen = false }) => {
39
39
  const [open, setOpen] = useState(defaultOpen);
40
40
  if (!data || Object.keys(data).length === 0)
41
41
  return null;
42
- return (_jsxs(Box, { mt: 3, children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 1, cursor: "pointer", py: 1, userSelect: "none", onClick: () => setOpen(!open), children: [open ? _jsx(ChevronDownIcon, { size: 16 }) : _jsx(ChevronRightIcon, { size: 16 }), _jsx(Text, { sx: { fontSize: 1, fontWeight: 'bold' }, children: title }), _jsx(CounterLabel, { children: Object.keys(data).length })] }), open && (_jsx(Box, { bg: "canvas.subtle", borderRadius: 2, border: "1px solid", borderColor: "border.default", p: 2, mt: 1, overflowX: "auto", children: Object.entries(data).map(([key, val]) => (_jsx(AttributeRow, { attrKey: key, value: val, depth: 0 }, key))) }))] }));
42
+ return (_jsxs(Box, { mt: 3, children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 1, cursor: "pointer", py: 1, userSelect: "none", onClick: () => setOpen(!open), children: [open ? _jsx(ChevronDownIcon, { size: 16 }) : _jsx(ChevronRightIcon, { size: 16 }), _jsx(Text, { sx: { fontSize: 1, fontWeight: 'bold' }, children: title }), _jsx(CounterLabel, { children: Object.keys(data).length })] }), open && (_jsx(Card, { border: true, shadow: "none", variant: "subtle", display: "block", p: 2, mt: 1, overflowX: "auto", children: Object.entries(data).map(([key, val]) => (_jsx(AttributeRow, { attrKey: key, value: val, depth: 0 }, key))) }))] }));
43
43
  };
44
44
  /**
45
45
  * A value as the detail unfolds it: JSON text of an object or an array (the
@@ -107,7 +107,7 @@ export const OtelSpanDetail = ({ span, traceSpans, onClose, }) => {
107
107
  }
108
108
  // Build tree from traceSpans for tree tab
109
109
  const tree = traceSpans && traceSpans.length > 0 ? buildSpanTree(traceSpans) : undefined;
110
- return (_jsxs(Box, { height: "100%", overflow: "auto", borderLeft: "1px solid", borderColor: "border.default", bg: "canvas.default", display: "flex", flexDirection: "column", children: [_jsxs(Box, { px: 3, py: 2, borderBottom: "1px solid", borderColor: "border.default", display: "flex", justifyContent: "space-between", alignItems: "center", bg: "canvas.subtle", flexShrink: 0, children: [_jsx(Text, { sx: { fontSize: 2, fontWeight: 'bold' }, children: span.span_name }), onClose && (_jsx(IconButton, { icon: XIcon, "aria-label": "Close detail panel", variant: "invisible", size: "small", onClick: onClose }))] }), _jsxs(UnderlineNav, { "aria-label": "Span detail tabs", children: [_jsx(UnderlineNav.Item, { "aria-current": activeTab === 'details' ? 'page' : undefined, onClick: () => setActiveTab('details'), children: "Details" }), tree && (_jsx(UnderlineNav.Item, { "aria-current": activeTab === 'tree' ? 'page' : undefined, onClick: () => setActiveTab('tree'), children: "Trace Tree" })), _jsx(UnderlineNav.Item, { "aria-current": activeTab === 'raw' ? 'page' : undefined, onClick: () => setActiveTab('raw'), children: "Raw Data" })] }), _jsxs(Box, { flex: 1, overflow: "auto", children: [activeTab === 'details' && (_jsxs(Box, { p: 3, children: [_jsx(MetadataRow, { label: "span_name", value: span.span_name }), _jsx(MetadataRow, { label: "service_name", value: span.service_name }), _jsx(MetadataRow, { label: "otel_scope_name", value: span.otel_scope_name }), _jsx(MetadataRow, { label: "kind", value: span.kind }), _jsx(MetadataRow, { label: "trace_id", value: span.trace_id, mono: true }), _jsx(MetadataRow, { label: "span_id", value: span.span_id, mono: true }), _jsx(MetadataRow, { label: "parent_span_id", value: span.parent_span_id, mono: true }), _jsx(MetadataRow, { label: "duration", value: formatDuration(span.duration_ms) }), _jsx(MetadataRow, { label: "start_time", value: span.start_time, mono: true }), _jsx(MetadataRow, { label: "end_time", value: span.end_time, mono: true }), _jsx(MetadataRow, { label: "status_code", value: span.status_code }), _jsx(MetadataRow, { label: "status_message", value: span.status_message }), _jsx(CollapsibleSection, { title: "Arguments", data: genAiAttrs, defaultOpen: Object.keys(genAiAttrs).length > 0 }), _jsx(CollapsibleSection, { title: "Attributes", data: otherAttrs }), span.events && span.events.length > 0 && (_jsxs(Box, { mt: 3, children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 1, children: [_jsx(Text, { sx: { fontWeight: 'bold', fontSize: 1 }, children: "Events" }), _jsx(CounterLabel, { children: span.events.length })] }), span.events.map((ev, idx) => (_jsxs(Box, { bg: "canvas.subtle", borderRadius: 2, border: "1px solid", borderColor: "border.default", p: 2, mt: 2, children: [_jsxs(Box, { display: "flex", gap: 2, children: [_jsx(Label, { children: ev.name }), _jsx(Text, { sx: {
110
+ return (_jsxs(Box, { height: "100%", overflow: "auto", borderLeft: "1px solid", borderColor: "border.default", bg: "canvas.default", display: "flex", flexDirection: "column", children: [_jsxs(Box, { px: 3, py: 2, borderBottom: "1px solid", borderColor: "border.default", display: "flex", justifyContent: "space-between", alignItems: "center", bg: "canvas.subtle", flexShrink: 0, children: [_jsx(Text, { sx: { fontSize: 2, fontWeight: 'bold' }, children: span.span_name }), onClose && (_jsx(IconButton, { icon: XIcon, "aria-label": "Close detail panel", variant: "invisible", size: "small", onClick: onClose }))] }), _jsxs(UnderlineNav, { "aria-label": "Span detail tabs", children: [_jsx(UnderlineNav.Item, { "aria-current": activeTab === 'details' ? 'page' : undefined, onClick: () => setActiveTab('details'), children: "Details" }), tree && (_jsx(UnderlineNav.Item, { "aria-current": activeTab === 'tree' ? 'page' : undefined, onClick: () => setActiveTab('tree'), children: "Trace Tree" })), _jsx(UnderlineNav.Item, { "aria-current": activeTab === 'raw' ? 'page' : undefined, onClick: () => setActiveTab('raw'), children: "Raw Data" })] }), _jsxs(Box, { flex: 1, overflow: "auto", children: [activeTab === 'details' && (_jsxs(Box, { p: 3, children: [_jsx(MetadataRow, { label: "span_name", value: span.span_name }), _jsx(MetadataRow, { label: "service_name", value: span.service_name }), _jsx(MetadataRow, { label: "otel_scope_name", value: span.otel_scope_name }), _jsx(MetadataRow, { label: "kind", value: span.kind }), _jsx(MetadataRow, { label: "trace_id", value: span.trace_id, mono: true }), _jsx(MetadataRow, { label: "span_id", value: span.span_id, mono: true }), _jsx(MetadataRow, { label: "parent_span_id", value: span.parent_span_id, mono: true }), _jsx(MetadataRow, { label: "duration", value: formatDuration(span.duration_ms) }), _jsx(MetadataRow, { label: "start_time", value: span.start_time, mono: true }), _jsx(MetadataRow, { label: "end_time", value: span.end_time, mono: true }), _jsx(MetadataRow, { label: "status_code", value: span.status_code }), _jsx(MetadataRow, { label: "status_message", value: span.status_message }), _jsx(CollapsibleSection, { title: "Arguments", data: genAiAttrs, defaultOpen: Object.keys(genAiAttrs).length > 0 }), _jsx(CollapsibleSection, { title: "Attributes", data: otherAttrs }), span.events && span.events.length > 0 && (_jsxs(Box, { mt: 3, children: [_jsxs(Box, { display: "flex", alignItems: "center", gap: 1, children: [_jsx(Text, { sx: { fontWeight: 'bold', fontSize: 1 }, children: "Events" }), _jsx(CounterLabel, { children: span.events.length })] }), span.events.map((ev, idx) => (_jsxs(Card, { border: true, shadow: "none", variant: "subtle", display: "block", p: 2, mt: 2, children: [_jsxs(Box, { display: "flex", gap: 2, children: [_jsx(Label, { children: ev.name }), _jsx(Text, { sx: {
111
111
  fontSize: 0,
112
112
  color: 'fg.muted',
113
113
  fontFamily: 'mono',