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