Line data Source code
1 : import * as React from 'react';
2 :
3 : import * as connectionTypes from '@ankoh/dashql-jsonschema/connection.js';
4 :
5 : import type { EmbeddedConnection } from '../platform/database/embedded_database.js';
6 : import { useEmbeddedDatabaseSetup } from '../platform/database/embedded_database_provider.js';
7 : import { stringifyError } from '../platform/logger/logger.js';
8 : import { useLogger } from '../platform/logger/logger_provider.js';
9 : import { useDashQLCoreSetup, useDashQLCoreSetupProgress } from '../runtime/core_provider.js';
10 : import { createDashQLShell, type DashQLShell, type DashQLShellCommand } from '../shell/api.js';
11 : import type { BrowserShellController } from '../shell/browser_shell.js';
12 : import { analyzeTable } from '../compute/analyze_table.js';
13 : import { useComputationDispatch } from '../compute/computation_registry.js';
14 : import { DELETE_COMPUTATION } from '../compute/computation_state.js';
15 : import { ShellQueryResultOverlay } from '../session/shell/shell_query_result_overlay.js';
16 : import { EmbeddedDatabaseCatalog } from './embedded_database_catalog.js';
17 : import { createEmbeddedDatabaseShellEnvironment } from './embedded_database_shell_environment.js';
18 : import { createLoginCommand, type SalesforceLoginAuthentication } from './commands/login.js';
19 : import { SalesforceLoginHistoryStore } from './salesforce_login_history.js';
20 : import { createExamplesCommand } from '../shell/shell_examples.js';
21 : import { useShellQueryHistory } from './shell_query_history.js';
22 : import { createShellLastCommand } from '../shell/shell_result.js';
23 : import { externalDirectoryDatabase, externalFileDatabase, ShellFileRegistry } from '../shell/shell_files.js';
24 : import { useShellFiles } from '../shell/filesystem_dialog.js';
25 : import { createFormatCommand } from './commands/format.js';
26 : import { createPlanCommand } from './commands/plan.js';
27 : import { createShareCommand } from './commands/share.js';
28 : import { useSalesforceLoginDialog } from './salesforce_login_dialog.js';
29 : import { SalesforceApiClient } from '../connections/salesforce/salesforce_api_client.js';
30 : import { authenticateSalesforce } from '../connections/salesforce/salesforce_authentication.js';
31 : import { useHttpClient } from '../platform/http/http_client_provider.js';
32 : import { usePlatformEventListener } from '../platform/events/event_listener_provider.js';
33 : import { PlatformType, usePlatformType } from '../platform/platform_type.js';
34 : import { useAppConfig } from '../config/app_config.js';
35 : import { getKeywordCase } from '../session/scripts/script_format.js';
36 : import { useShellQueryResult } from './use_shell_query_result.js';
37 : import { useFormatDialog } from './format_dialog.js';
38 : import { usePlanDialog } from './plan_dialog.js';
39 : import { useShareDialog } from './share_dialog.js';
40 : import { useSnippetsDialog } from '../session/snippets/snippets_dialog.js';
41 : import { decodeShareScript, readShareScriptFromLocation } from './shell_share.js';
42 : import { splitShellScript } from '../shell/shell_script.js';
43 : import { ConnectorType } from '../connections/connector_info.js';
44 : import { getHyperDBWebSupportError } from '../platform/hyperdb/hyperdb_web_support.js';
45 : import * as styles from './shell_page.module.css';
46 :
47 1 : const LOG_CTX = 'standalone_shell';
48 :
49 : interface ShellPageProps {
50 : onEngineVersion: (version: string) => void;
51 : }
52 :
53 1 : function formatInstantiationProgress(bytesLoaded: number, bytesTotal: number): string {
54 1 : if (bytesTotal <= 0) return `Loading ${Math.round(bytesLoaded / 1000)} kB`;
55 1 : const blocks = Math.max(0, Math.min(10, Math.floor(bytesLoaded / bytesTotal * 10)));
56 1 : return `Loading [${'#'.repeat(blocks)}${'-'.repeat(10 - blocks)}]`;
57 1 : }
58 :
59 1 : const INSTANTIATING_SHELL = 'Instantiating shell';
60 :
61 1 : export const ShellPage: React.FC<ShellPageProps> = (props: ShellPageProps) => {
62 1 : const logger = useLogger();
63 1 : const setupCore = useDashQLCoreSetup();
64 1 : const coreProgress = useDashQLCoreSetupProgress();
65 1 : const setupEmbeddedDatabase = useEmbeddedDatabaseSetup();
66 1 : const queryExecutions = useShellQueryHistory();
67 1 : const dispatchComputation = useComputationDispatch();
68 1 : const httpClient = useHttpClient();
69 1 : const appEvents = usePlatformEventListener();
70 1 : const platformType = usePlatformType();
71 1 : const appConfig = useAppConfig();
72 1 : const keywordCase = getKeywordCase(appConfig?.settings);
73 1 : const hyperDBSupportError = platformType === PlatformType.WEB ? getHyperDBWebSupportError() : null;
74 1 : const keywordCaseRef = React.useRef(keywordCase);
75 1 : keywordCaseRef.current = keywordCase;
76 : // The login reads the config when it runs, a config that arrives later must not tear down the shell
77 1 : const appConfigRef = React.useRef(appConfig);
78 1 : appConfigRef.current = appConfig;
79 1 : const loginHistoryRef = React.useRef(new SalesforceLoginHistoryStore());
80 1 : const { controller: loginDialog, dialog } = useSalesforceLoginDialog({
81 1 : nativeOAuth: platformType !== PlatformType.WEB,
82 1 : loadHistory: () => loginHistoryRef.current.load(),
83 1 : deleteHistoryEntry: organizationId => loginHistoryRef.current.delete(organizationId),
84 1 : });
85 1 : const { controller: formatDialog, dialog: formatterDialog } = useFormatDialog();
86 1 : const { controller: planDialog, dialog: queryPlanDialog } = usePlanDialog(appEvents);
87 1 : const { controller: shareDialog, dialog: shareScriptDialog } = useShareDialog();
88 1 : const { controller: snippetsDialog, dialog: snippetsBrowserDialog } = useSnippetsDialog(ConnectorType.HYPER);
89 1 : const fileRegistryRef = React.useRef<ShellFileRegistry | null>(null);
90 1 : const { command: filesCommand, dialogs: fileDialogs } = useShellFiles(fileRegistryRef);
91 1 : const containerRef = React.useRef<HTMLDivElement>(null);
92 1 : const [status, setStatus] = React.useState('Instantiating hyperdb-wasm');
93 1 : const shellRef = React.useRef<DashQLShell | null>(null);
94 1 : const controllerRef = React.useRef<BrowserShellController | null>(null);
95 1 : React.useEffect(() => {
96 1 : shellRef.current?.setKeywordCase(keywordCase);
97 1 : }, [keywordCase]);
98 1 : const releaseResult = React.useCallback((queryId: number) => {
99 1 : dispatchComputation({ type: DELETE_COMPUTATION, value: [queryId] });
100 1 : }, [dispatchComputation]);
101 1 : const { resultQuery, showResultQuery, closeResultQuery, reopenResultQuery, dropResultQuery } = useShellQueryResult(queryExecutions, releaseResult);
102 1 : const showResultError = (error: unknown, info: React.ErrorInfo) => {
103 0 : dropResultQuery();
104 : // The terminal says what happened, an error would also raise a toast
105 0 : logger.warn('Failed to show a query result', { error: stringifyError(error), stack: info.componentStack ?? '' }, LOG_CTX);
106 0 : controllerRef.current?.writeStatus(`Failed to show the result: ${stringifyError(error)}`);
107 0 : };
108 :
109 1 : React.useEffect(() => {
110 1 : if (hyperDBSupportError != null) return;
111 1 : if (containerRef.current == null) return;
112 :
113 1 : let cancelled = false;
114 1 : let connection: EmbeddedConnection | null = null;
115 1 : let shell: DashQLShell | null = null;
116 1 : let shellCatalog: EmbeddedDatabaseCatalog | null = null;
117 1 : let controller: BrowserShellController | null = null;
118 1 : let fileRegistry: ShellFileRegistry | null = null;
119 :
120 1 : const setup = async () => {
121 1 : setStatus('Instantiating hyperdb-wasm');
122 1 : const database = await setupEmbeddedDatabase(LOG_CTX, progress => {
123 1 : if (!cancelled) {
124 1 : setStatus(`Instantiating hyperdb-wasm: ${formatInstantiationProgress(progress.bytesLoaded, progress.bytesTotal)}`);
125 : }
126 1 : });
127 1 : void database.getVersion()
128 1 : .then(version => {
129 1 : if (!cancelled) props.onEngineVersion(version);
130 1 : })
131 1 : .catch(error => {
132 0 : logger.warn('Failed to load engine version', { error: stringifyError(error) }, LOG_CTX);
133 1 : });
134 1 : const nextConnection = await database.connect({ defaultDatabase: 'hyper' });
135 1 : if (cancelled) {
136 0 : await nextConnection.close();
137 0 : return;
138 0 : }
139 1 : connection = nextConnection;
140 :
141 1 : setStatus(INSTANTIATING_SHELL);
142 1 : const core = await setupCore(LOG_CTX);
143 1 : const loginCommand = createLoginCommand({
144 1 : requestForm: loginDialog.request,
145 1 : authenticate: async (form, signal, onProgress) => {
146 0 : if (!httpClient) throw new Error('HTTP client is not ready');
147 0 : const oauthRedirect = appConfigRef.current?.connectors?.salesforce?.auth?.oauthRedirect;
148 0 : if (!oauthRedirect) throw new Error('Salesforce OAuth redirect is not configured');
149 0 : const abortSignal = signal ?? new AbortController().signal;
150 0 : const params: connectionTypes.SalesforceConnectionParams = {
151 0 : instanceUrl: form.instanceUrl,
152 0 : appConsumerKey: form.appConsumerKey,
153 0 : appConsumerSecret: '',
154 0 : login: form.loginHint,
155 0 : };
156 0 : return await authenticateSalesforce({
157 0 : logger,
158 0 : params,
159 0 : authConfig: { oauthRedirect },
160 0 : platformType,
161 0 : apiClient: new SalesforceApiClient(logger, httpClient),
162 0 : appEvents,
163 0 : forceReLogin: appConfigRef.current?.settings?.forceReLogin ?? false,
164 0 : abortSignal,
165 0 : oauthPopup: form.oauthPopup,
166 0 : onProgress: progress => {
167 : const messages: Partial<Record<typeof progress.stage, string>> = {
168 0 : GENERATING_PKCE_CHALLENGE: 'Preparing secure Salesforce login',
169 0 : OAUTH_NATIVE_LINK_OPENED: 'Waiting for Salesforce authorization in your browser',
170 0 : OAUTH_WEB_WINDOW_OPENED: 'Waiting for Salesforce authorization',
171 0 : REQUESTING_CORE_AUTH_TOKEN: 'Exchanging Salesforce authorization code',
172 0 : REQUESTING_DATA_CLOUD_ACCESS_TOKEN: 'Requesting Data Cloud credentials',
173 : };
174 0 : const message = messages[progress.stage];
175 0 : if (message) {
176 0 : onProgress?.(message);
177 0 : loginDialog.update({ status: message });
178 0 : }
179 0 : if (progress.stage === 'RECEIVED_CORE_AUTH_TOKEN') {
180 0 : loginDialog.update({
181 0 : status: 'Received Core access token',
182 0 : coreAccessToken: progress.coreAccessToken,
183 0 : });
184 0 : } else if (progress.stage === 'RECEIVED_CORE_USER_INFO') {
185 0 : loginDialog.update({
186 0 : login: progress.coreUserInfo.preferredUsername ?? progress.coreUserInfo.email ?? '',
187 0 : });
188 0 : } else if (progress.stage === 'RECEIVED_DATA_CLOUD_ACCESS_TOKEN') {
189 0 : loginDialog.update({
190 0 : status: 'Received Data Cloud access token',
191 0 : dataCloudAccessToken: progress.dataCloudAccessToken,
192 0 : });
193 : }
194 0 : },
195 0 : });
196 0 : },
197 1 : onSuccess: async (form, authentication) => {
198 0 : const organizationId = authentication.coreUserInfo?.organizationId
199 0 : ?? authentication.dataCloudAccessToken.jwt.payload.orgId;
200 0 : if (organizationId) {
201 0 : try {
202 0 : await loginHistoryRef.current.record({
203 0 : organizationId,
204 0 : name: form.alias,
205 0 : instanceUrl: authentication.coreAccessToken.instanceUrl ?? form.instanceUrl,
206 0 : appConsumerKey: form.appConsumerKey,
207 0 : loginHint: authentication.coreUserInfo?.preferredUsername
208 0 : ?? authentication.coreUserInfo?.email,
209 0 : });
210 0 : } catch (error) {
211 0 : logger.warn('Failed to persist Salesforce login history', {
212 0 : error: stringifyError(error),
213 0 : }, LOG_CTX);
214 0 : }
215 : } else {
216 0 : logger.warn('Salesforce login did not return an organization ID; history was not updated', {}, LOG_CTX);
217 : }
218 0 : loginDialog.succeed(`Authenticated with Salesforce as ${form.alias}`);
219 0 : },
220 1 : onError: error => {
221 0 : loginDialog.fail(stringifyError(error));
222 0 : return 'retry';
223 0 : },
224 1 : });
225 1 : fileRegistry = new ShellFileRegistry(
226 1 : logger,
227 1 : externalFileDatabase(database),
228 1 : externalDirectoryDatabase(database),
229 1 : );
230 1 : fileRegistryRef.current = fileRegistry;
231 1 : const commands: DashQLShellCommand[] = [
232 1 : createExamplesCommand({
233 1 : requestSnippet: snippetsDialog.request,
234 1 : splitPrompts: text => {
235 0 : if (shell == null) throw new Error('Shell is not ready');
236 0 : return splitShellScript(shell.core, shell.catalog, text);
237 0 : },
238 : // The prompts are filled in once the command finished
239 1 : queuePrompts: prompts => controller?.queuePrompts(prompts),
240 1 : }),
241 1 : createFormatCommand({
242 1 : requestDialog: signal => {
243 0 : if (shell == null) throw new Error('Shell is not ready');
244 0 : return formatDialog.request(shell.core, shell.catalog, signal);
245 0 : },
246 1 : }),
247 1 : createPlanCommand({
248 1 : requestDialog: signal => {
249 0 : if (shell == null) throw new Error('Shell is not ready');
250 0 : return planDialog.request(shell.core, signal);
251 0 : },
252 1 : }),
253 1 : createShareCommand({
254 1 : requestDialog: signal => {
255 0 : if (shell == null) throw new Error('Shell is not ready');
256 0 : return shareDialog.request(shell.core, shell.catalog, signal);
257 0 : },
258 1 : }),
259 1 : loginCommand,
260 1 : createShellLastCommand(reopenResultQuery),
261 1 : filesCommand,
262 1 : ];
263 1 : const nextShell = await createDashQLShell({
264 1 : core,
265 1 : environment: createEmbeddedDatabaseShellEnvironment(connection, queryExecutions, {
266 1 : prepareResult: (queryId, table) => analyzeTable(
267 1 : queryId,
268 1 : table,
269 1 : dispatchComputation,
270 1 : database,
271 1 : logger,
272 1 : ),
273 : // The statement succeeded, following the catalog must not fail it
274 1 : afterStatement: async statement => {
275 1 : try {
276 1 : await shellCatalog?.afterStatement(statement);
277 1 : } catch (error) {
278 0 : logger.warn('Failed to update the catalog of the shell', { error: stringifyError(error) }, LOG_CTX);
279 0 : }
280 1 : },
281 1 : }),
282 1 : autoQualifyNonDefaultDatabaseTables: true,
283 1 : commands,
284 1 : });
285 1 : if (cancelled || containerRef.current == null) {
286 0 : nextShell.destroy();
287 0 : return;
288 0 : }
289 1 : shell = nextShell;
290 1 : shellCatalog = new EmbeddedDatabaseCatalog(nextShell.core, nextShell.catalog, text => connection!.query(text));
291 1 : shellRef.current = nextShell;
292 1 : nextShell.setKeywordCase(keywordCaseRef.current);
293 1 : const { embedDashQLShell } = await import('../shell/browser_shell.js');
294 1 : if (cancelled || containerRef.current == null) return;
295 1 : const nextController = await embedDashQLShell({
296 1 : container: containerRef.current,
297 1 : shell,
298 1 : greeter: [
299 1 : 'HyperDB Web Shell',
300 1 : 'This is an embedded version of the Hyper Database Engine.',
301 1 : 'Enter .help for usage hints.'
302 1 : ],
303 1 : prompt: 'hyperdb> ',
304 1 : inputAriaLabel: 'HyperDB shell input',
305 1 : onQueryResult: showResultQuery,
306 1 : });
307 1 : if (cancelled) {
308 0 : nextController.dispose();
309 0 : return;
310 0 : }
311 1 : controller = nextController;
312 1 : controllerRef.current = nextController;
313 1 : setStatus('');
314 :
315 : // Offer the statements of a shared script one after another
316 1 : const sharedScript = readShareScriptFromLocation();
317 1 : if (sharedScript == null) return;
318 0 : try {
319 0 : const text = await decodeShareScript(sharedScript);
320 0 : if (cancelled) return;
321 0 : const prompts = splitShellScript(nextShell.core, nextShell.catalog, text);
322 0 : if (prompts.length === 0) return;
323 1 : nextController.writeStatus(`Shared script with ${prompts.length} ${prompts.length === 1 ? 'statement' : 'statements'}. Press Enter to run each, Ctrl+C to skip the rest.`);
324 1 : nextController.queuePrompts(prompts);
325 1 : } catch (error) {
326 0 : if (cancelled) return;
327 0 : logger.warn('Failed to load shared script', { error: stringifyError(error) }, LOG_CTX);
328 0 : nextController.writeStatus(`Failed to load shared script: ${stringifyError(error)}`);
329 0 : }
330 1 : };
331 :
332 1 : void setup().catch(error => {
333 0 : if (cancelled) return;
334 0 : const message = `Failed to load shell: ${stringifyError(error)}`;
335 0 : logger.warn(message, {}, LOG_CTX);
336 0 : setStatus(message);
337 1 : });
338 :
339 1 : return () => {
340 1 : cancelled = true;
341 1 : controller?.dispose();
342 1 : controllerRef.current = null;
343 1 : shellCatalog?.destroy();
344 1 : shell?.destroy();
345 1 : shellRef.current = null;
346 1 : fileRegistryRef.current = null;
347 1 : void fileRegistry?.dispose().finally(() => connection?.close());
348 1 : };
349 1 : }, [appEvents, dispatchComputation, filesCommand, setupCore, formatDialog, httpClient, hyperDBSupportError, logger, loginDialog, planDialog, platformType, props.onEngineVersion, queryExecutions, reopenResultQuery, setupEmbeddedDatabase, shareDialog, showResultQuery, snippetsDialog]);
350 :
351 1 : if (hyperDBSupportError != null) {
352 0 : return (
353 0 : <main className={styles.page} aria-label="HyperDB Shell">
354 0 : <section className={styles.unsupported} role="alert">
355 0 : <h1>Unsupported platform</h1>
356 0 : <p>{hyperDBSupportError}</p>
357 0 : </section>
358 0 : </main>
359 : );
360 : }
361 :
362 1 : return (
363 1 : <main className={styles.page} aria-label="HyperDB Shell">
364 1 : <div className={styles.terminal}>
365 1 : <div ref={containerRef} className={styles.terminalHost} />
366 1 : </div>
367 1 : {status && (
368 1 : <div className={styles.status} role="status" aria-live="polite">
369 1 : <strong>[ RUN ]</strong> {status === INSTANTIATING_SHELL && coreProgress != null
370 1 : ? `${status}: ${formatInstantiationProgress(Number(coreProgress.bytesLoaded), Number(coreProgress.bytesTotal))}`
371 1 : : status}
372 1 : </div>
373 : )}
374 1 : {resultQuery != null && (
375 1 : <ShellQueryResultOverlay
376 1 : query={resultQuery}
377 1 : onClose={closeResultQuery}
378 1 : onError={showResultError}
379 1 : dismissOnClickOutside={false}
380 1 : />
381 : )}
382 1 : {dialog}
383 1 : {formatterDialog}
384 1 : {queryPlanDialog}
385 1 : {shareScriptDialog}
386 1 : {snippetsBrowserDialog}
387 1 : {fileDialogs}
388 1 : </main>
389 : );
390 1 : };
|