@oceanum/oceanumlab 4.7.0 → 4.7.1

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 (59) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +18 -2
  5. package/lib/DatameshWidget.js +70 -11
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +13 -1
  23. package/lib/chatRouter.js +9 -2
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +6 -6
  28. package/lib/index.d.ts +8 -1
  29. package/lib/index.js +56 -17
  30. package/lib/specStore.d.ts +37 -0
  31. package/lib/specStore.js +57 -0
  32. package/package.json +9 -1
  33. package/schema/datamesh-connect.json +3 -12
  34. package/src/DatameshUI.tsx +1 -10
  35. package/src/DatameshWidget.tsx +124 -19
  36. package/src/StoredNotebooks.tsx +166 -0
  37. package/src/Tabs.tsx +102 -0
  38. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  39. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  40. package/src/__tests__/Tabs.spec.tsx +168 -0
  41. package/src/__tests__/aiBackend.spec.ts +5 -6
  42. package/src/__tests__/chatRouter.spec.ts +9 -15
  43. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  44. package/src/__tests__/pluginArity.spec.ts +24 -0
  45. package/src/__tests__/serverAuth.spec.ts +277 -0
  46. package/src/__tests__/settingsSchema.spec.ts +73 -0
  47. package/src/__tests__/specStore.spec.ts +126 -0
  48. package/src/aiBackend.ts +1 -1
  49. package/src/auth/accountWidget.ts +113 -0
  50. package/src/auth/claims.ts +30 -0
  51. package/src/auth/plugin.ts +155 -0
  52. package/src/auth/serverAuth.ts +341 -0
  53. package/src/auth/tokens.ts +104 -0
  54. package/src/chatRouter.ts +8 -4
  55. package/src/constants.ts +1 -1
  56. package/src/datameshUiUrl.ts +6 -6
  57. package/src/index.ts +85 -36
  58. package/src/specStore.ts +82 -0
  59. package/style/index.css +158 -0
package/src/index.ts CHANGED
@@ -18,6 +18,9 @@ import { requestAPI } from './handler';
18
18
  import { ChatRouter, ChatRouterError, ChatMessage } from './chatRouter';
19
19
  import { reporterFor } from './progress';
20
20
  import { ConversationPin } from './conversationPin';
21
+ import { deviceAuthPlugin } from './auth/plugin';
22
+ import { IOceanumAuth } from './auth/tokens';
23
+
21
24
  import { snapshotFromIpynb, snapshotOf } from './notebookContext';
22
25
  import { notebookHost } from './notebookHost';
23
26
  import { KernelHandoff } from './kernelHandoff';
@@ -53,20 +56,27 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
53
56
  ISettingRegistry,
54
57
  IStateDB
55
58
  ],
59
+ // Optional so the panel still loads on a host with no Oceanum environment; the
60
+ // Notebooks tab then explains why it is empty instead of the panel failing to start.
61
+ optional: [IOceanumAuth],
56
62
  activate: (
57
63
  app: JupyterFrontEnd,
58
64
  palette: ICommandPalette,
59
65
  restorer: ILayoutRestorer,
60
66
  status: ILabStatus,
61
- settingRegistry: ISettingRegistry
67
+ settingRegistry: ISettingRegistry,
68
+ stateDB: IStateDB,
69
+ auth: IOceanumAuth | null
62
70
  ) => {
63
71
  console.log('Oceanum datamesh connect extension is loaded');
64
72
 
65
- // The `datameshUiUrl` setting; the default until the settings load.
66
- let datameshUiUrl = DATAMESH_UI_SERVICE;
67
- // The `aiBackendUrl` and `datameshToken` settings for the AI chat; the
68
- // defaults until the settings load.
69
- let aiBackendUrl = OCEANUM_AI_BACKEND_URL;
73
+ // Where the Datamesh UI and Oceanum AI live is the deployment's business, so it comes
74
+ // from the sign-in environment (the Jupyter server's configuration, or the notebook
75
+ // site's) and falls back to production. These were user settings until 4.7.1; a
76
+ // user-editable service address is somewhere to send the user's credential.
77
+ const datameshUiUrl = auth?.urls?.datameshUi ?? DATAMESH_UI_SERVICE;
78
+ const aiBackendUrl = auth?.urls?.ai ?? OCEANUM_AI_BACKEND_URL;
79
+ // The `datameshToken` setting for the AI chat; empty until the settings load.
70
80
  let datameshTokenSetting = '';
71
81
  // Tells the sidebar the settings above have changed.
72
82
  const settingsChanged = new Signal<JupyterFrontEnd, void>(app);
@@ -88,14 +98,7 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
88
98
  }).then(res => console.log(res));
89
99
  }
90
100
  window.injectToken = set.get('injectToken').user as boolean;
91
- aiBackendUrl = set.get('aiBackendUrl').composite as string;
92
101
  datameshTokenSetting = set.get('datameshToken').composite as string;
93
- // An open Datamesh UI panel follows a change of address.
94
- datameshUiUrl = set.get('datameshUiUrl').composite as string;
95
- const datameshUI = findDatameshUI();
96
- if (datameshUI) {
97
- datameshUI.url = datameshUiUrl;
98
- }
99
102
  settingsChanged.emit();
100
103
  };
101
104
  //Try to get the datamesh token from the envars
@@ -145,7 +148,13 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
145
148
  aiBackendUrl: () => aiBackendUrl,
146
149
  settingsChanged,
147
150
  commands: app.commands,
148
- getCurrentWidget
151
+ getCurrentWidget,
152
+ auth
153
+ // No `openStoredNotebook`: nothing can open a spec store record by id yet.
154
+ // share-oceanum has `oceanum-share:open`, but it takes no arguments — it opens
155
+ // its own picker — so there is nothing to hand an id to. Opening arrives with
156
+ // the drive in OCE-182, where the contents API makes it the ordinary path.
157
+ // Until then the list reads rather than offering a click that does nothing.
149
158
  });
150
159
  datameshConnectWidget.id = 'datamesh-connect';
151
160
  datameshConnectWidget.title.icon = oceanumIcon;
@@ -153,8 +162,30 @@ export const datamesh_connect_extension: JupyterFrontEndPlugin<void> = {
153
162
 
154
163
  restorer.add(datameshConnectWidget, 'datamesh-connect');
155
164
 
165
+ // The layout restorer restores the widget's place in the shell, not fields on it,
166
+ // so the chosen tab is kept here. Restoring is best effort: a missing or unreadable
167
+ // entry just leaves the default.
168
+ const TAB_STATE_KEY = `${PLUGIN_ID}:tab`;
169
+ void stateDB
170
+ .fetch(TAB_STATE_KEY)
171
+ .then(value => {
172
+ if (typeof value === 'string') {
173
+ datameshConnectWidget.selectTab(value);
174
+ }
175
+ })
176
+ .catch((): void => undefined);
177
+ datameshConnectWidget.tabChanged.connect((_, id) => {
178
+ void stateDB.save(TAB_STATE_KEY, id);
179
+ });
180
+
156
181
  // Rank has been chosen somewhat arbitrarily to give priority to the running
157
182
  // sessions widget in the sidebar.
183
+ //
184
+ // Oceanum Notebook wants this panel first instead, but that is a property of that
185
+ // distribution rather than of the extension: in a plain JupyterLab the file browser
186
+ // is the primary navigation surface and displacing it would be a regression. A
187
+ // distribution moves it with JupyterLab's own shell user-layout settings, keyed on
188
+ // this widget's id, rather than this extension hard-coding one host's preference.
158
189
  app.shell.add(datameshConnectWidget, 'left', { rank: 900 });
159
190
 
160
191
  app.commands.addCommand('datamesh-ui:open', {
@@ -174,10 +205,12 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
174
205
  id: '@oceanum/oceanumlab:ai-chat',
175
206
  autoStart: true,
176
207
  requires: [INotebookTracker, ISettingRegistry],
208
+ optional: [IOceanumAuth],
177
209
  activate: (
178
210
  app: JupyterFrontEnd,
179
211
  notebookTracker: INotebookTracker,
180
- settingRegistry: ISettingRegistry
212
+ settingRegistry: ISettingRegistry,
213
+ auth: IOceanumAuth | null
181
214
  ) => {
182
215
  console.log('Oceanum AI chat extension is loaded');
183
216
 
@@ -197,26 +230,31 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
197
230
  // The panel the current request read its selected cell from, if the
198
231
  // notebook was open: a code answer may replace that cell only there.
199
232
  let readFrom: NotebookPanel | null = null;
200
- const router = new ChatRouter(settings, app.commands, async () => {
201
- const open = await pin.current();
202
- readFrom = open;
203
- if (open) {
204
- return snapshotOf(open.content);
205
- }
206
- const path = pin.path();
207
- if (!path) {
208
- return null;
209
- }
210
- try {
211
- const file = await app.serviceManager.contents.get(path, {
212
- content: true
213
- });
214
- return snapshotFromIpynb(file.content);
215
- } catch {
216
- // Gone: an answer's cells will go into a new notebook instead.
217
- return null;
218
- }
219
- });
233
+ const router = new ChatRouter(
234
+ settings,
235
+ app.commands,
236
+ async () => {
237
+ const open = await pin.current();
238
+ readFrom = open;
239
+ if (open) {
240
+ return snapshotOf(open.content);
241
+ }
242
+ const path = pin.path();
243
+ if (!path) {
244
+ return null;
245
+ }
246
+ try {
247
+ const file = await app.serviceManager.contents.get(path, {
248
+ content: true
249
+ });
250
+ return snapshotFromIpynb(file.content);
251
+ } catch {
252
+ // Gone: an answer's cells will go into a new notebook instead.
253
+ return null;
254
+ }
255
+ },
256
+ () => auth?.urls?.ai ?? ''
257
+ );
220
258
  // Asked only when an answer has blocks to place: brings the
221
259
  // conversation's notebook to the front, opening it again if closed.
222
260
  const handoff = new KernelHandoff(
@@ -352,4 +390,15 @@ export const oceanum_ai_extension: JupyterFrontEndPlugin<void> = {
352
390
  }
353
391
  };
354
392
 
355
- export default [datamesh_connect_extension, oceanum_ai_extension];
393
+ /**
394
+ * The sign-in contract, exported so another extension can provide or consume it: a Lumino
395
+ * token is matched by object identity, so everyone has to import this one object, and
396
+ * JupyterLab shares `@oceanum/oceanumlab` between extensions as a singleton module.
397
+ */
398
+ export * from './auth/tokens';
399
+
400
+ export default [
401
+ deviceAuthPlugin,
402
+ datamesh_connect_extension,
403
+ oceanum_ai_extension
404
+ ];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Reading the Oceanum spec store's `notebook` records.
3
+ *
4
+ * Deliberately small: only what the Stored notebooks tab needs. oceanum-notebook's
5
+ * share-oceanum package has a fuller client (create, update, permissions), and when
6
+ * sharing moves here the two should become one rather than sitting side by side.
7
+ */
8
+
9
+ /** A spec store list entry. The list omits the notebook body. */
10
+ export interface ISpecSummary {
11
+ id: string;
12
+ name: string;
13
+ description: string | null;
14
+ /** Set when the record is created and never updated, so this is a creation time. */
15
+ modified: string;
16
+ /** Null unless the caller created the record: the store hides other creators. */
17
+ creator: string | null;
18
+ }
19
+
20
+ function isSummary(value: unknown): value is ISpecSummary {
21
+ if (typeof value !== 'object' || value === null) {
22
+ return false;
23
+ }
24
+ const v = value as Record<string, unknown>;
25
+ return (
26
+ typeof v.id === 'string' &&
27
+ typeof v.name === 'string' &&
28
+ typeof v.modified === 'string'
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Every notebook the signed-in user may read: their own and those shared with them.
34
+ *
35
+ * The store answers an unauthenticated caller with an empty list rather than an error,
36
+ * so a missing token is indistinguishable from an empty account — callers should check
37
+ * they are signed in before showing "no notebooks".
38
+ */
39
+ export async function listNotebooks(
40
+ specsUrl: string,
41
+ token: string,
42
+ fetcher: typeof fetch = fetch
43
+ ): Promise<ISpecSummary[]> {
44
+ const url = `${specsUrl.replace(/\/+$/, '')}/specs/notebook`;
45
+ const response = await fetcher(url, {
46
+ headers: { Authorization: `Bearer ${token}` }
47
+ });
48
+ if (!response.ok) {
49
+ // The store answers a bad or expired token with 400, and 403 both for no access
50
+ // and for a record that does not exist.
51
+ throw new Error(`Oceanum.io returned HTTP ${response.status}.`);
52
+ }
53
+ const data = await response.json();
54
+ if (!Array.isArray(data)) {
55
+ throw new Error('Oceanum.io returned an unexpected response.');
56
+ }
57
+ return data.filter(isSummary);
58
+ }
59
+
60
+ /**
61
+ * Split a listing into the caller's own notebooks and the rest, newest first.
62
+ *
63
+ * The store only sends `creator` on records the caller created, so anything else it
64
+ * returns is shared with them — directly, or because it is public. Addresses are
65
+ * compared case-insensitively: the store matches entities as case-sensitive globs, so
66
+ * the two sides can differ in case.
67
+ */
68
+ export function partitionNotebooks(
69
+ items: readonly ISpecSummary[],
70
+ email: string | null
71
+ ): { mine: ISpecSummary[]; shared: ISpecSummary[] } {
72
+ const me = email?.toLowerCase() ?? null;
73
+ const sorted = [...items].sort((a, b) =>
74
+ b.modified.localeCompare(a.modified)
75
+ );
76
+ const isMine = (item: ISpecSummary): boolean =>
77
+ me !== null && item.creator?.toLowerCase() === me;
78
+ return {
79
+ mine: sorted.filter(isMine),
80
+ shared: sorted.filter(item => !isMine(item))
81
+ };
82
+ }
package/style/index.css CHANGED
@@ -475,3 +475,161 @@
475
475
  border-top: 1px solid var(--jp-border-color2);
476
476
  margin: 0.5em 0;
477
477
  }
478
+
479
+ /* Oceanum sign-in: the top-bar account control (src/auth/accountWidget.ts). */
480
+ .oc-Account {
481
+ align-self: center;
482
+ margin: 0 8px;
483
+ padding: 2px 10px;
484
+ border: var(--jp-border-width) solid var(--jp-border-color1);
485
+ border-radius: 12px;
486
+ background: var(--jp-layout-color1);
487
+ color: var(--jp-ui-font-color1);
488
+ font-family: var(--jp-ui-font-family);
489
+ font-size: var(--jp-ui-font-size1);
490
+ white-space: nowrap;
491
+ cursor: pointer;
492
+ }
493
+
494
+ .oc-Account:hover {
495
+ background: var(--jp-layout-color2);
496
+ }
497
+
498
+ .oc-Account:not(.oc-mod-signedIn) {
499
+ border-color: var(--jp-brand-color1);
500
+ color: var(--jp-brand-color1);
501
+ }
502
+
503
+ /* The sign-in dialog: the device code, and the link that confirms it. */
504
+ .oc-SignIn {
505
+ max-width: 360px;
506
+ }
507
+
508
+ .oc-SignIn-code {
509
+ margin: 12px 0;
510
+ padding: 10px;
511
+ border: var(--jp-border-width) solid var(--jp-border-color1);
512
+ border-radius: 4px;
513
+ background: var(--jp-layout-color2);
514
+ font-family: var(--jp-code-font-family);
515
+ font-size: 22px;
516
+ letter-spacing: 3px;
517
+ text-align: center;
518
+ user-select: all;
519
+ }
520
+
521
+ .oc-SignIn-link {
522
+ display: block;
523
+ padding: 6px 12px;
524
+ border-radius: 2px;
525
+ text-align: center;
526
+ text-decoration: none;
527
+ }
528
+
529
+ .oc-SignIn-note {
530
+ margin-top: 12px;
531
+ color: var(--jp-ui-font-color2);
532
+ font-size: var(--jp-ui-font-size0);
533
+ }
534
+
535
+ /* The Oceanum side panel's tab strip (src/Tabs.tsx). */
536
+ .oceanum-tabs {
537
+ display: flex;
538
+ flex-direction: column;
539
+ min-height: 0;
540
+ flex: 1 1 auto;
541
+ }
542
+
543
+ .oceanum-tabs-strip {
544
+ display: flex;
545
+ border-bottom: var(--jp-border-width) solid var(--jp-border-color2);
546
+ }
547
+
548
+ .oceanum-tab {
549
+ flex: 1 1 auto;
550
+ padding: 6px 4px;
551
+ border: none;
552
+ border-bottom: 2px solid transparent;
553
+ background: transparent;
554
+ color: var(--jp-ui-font-color2);
555
+ font-family: var(--jp-ui-font-family);
556
+ font-size: var(--jp-ui-font-size1);
557
+ cursor: pointer;
558
+ }
559
+
560
+ .oceanum-tab:hover {
561
+ background: var(--jp-layout-color2);
562
+ }
563
+
564
+ .oceanum-tab-selected {
565
+ border-bottom-color: var(--jp-brand-color1);
566
+ color: var(--jp-ui-font-color1);
567
+ }
568
+
569
+ .oceanum-tabpanel {
570
+ display: flex;
571
+ flex-direction: column;
572
+ min-height: 0;
573
+ flex: 1 1 auto;
574
+ overflow: auto;
575
+ }
576
+
577
+ /* Must come after the rule above: an author `display` beats the user agent's
578
+ * `[hidden] { display: none }`, so without this every pane renders at once and the
579
+ * tabs only move the underline. */
580
+ .oceanum-tabpanel[hidden] {
581
+ display: none;
582
+ }
583
+
584
+ /* Stored notebooks (src/StoredNotebooks.tsx). */
585
+ .oceanum-notebooks-heading {
586
+ display: flex;
587
+ justify-content: space-between;
588
+ padding: 6px 8px 2px;
589
+ color: var(--jp-ui-font-color2);
590
+ font-size: var(--jp-ui-font-size0);
591
+ text-transform: uppercase;
592
+ }
593
+
594
+ .oceanum-notebooks-list {
595
+ margin: 0;
596
+ padding: 0;
597
+ list-style: none;
598
+ }
599
+
600
+ .oceanum-notebooks-item {
601
+ display: block;
602
+ width: 100%;
603
+ padding: 4px 8px;
604
+ border: none;
605
+ background: transparent;
606
+ color: var(--jp-ui-font-color1);
607
+ font-family: var(--jp-ui-font-family);
608
+ font-size: var(--jp-ui-font-size1);
609
+ text-align: left;
610
+ cursor: pointer;
611
+ }
612
+
613
+ .oceanum-notebooks-item:hover {
614
+ background: var(--jp-layout-color2);
615
+ }
616
+
617
+ .oceanum-notebooks-item-static {
618
+ cursor: default;
619
+ }
620
+
621
+ .oceanum-notebooks-item-static:hover {
622
+ background: transparent;
623
+ }
624
+
625
+ .oceanum-notebooks-name {
626
+ overflow: hidden;
627
+ text-overflow: ellipsis;
628
+ white-space: nowrap;
629
+ }
630
+
631
+ .oceanum-text-error {
632
+ padding: 8px;
633
+ color: var(--jp-error-color1);
634
+ font-size: var(--jp-ui-font-size1);
635
+ }