LCOV - code coverage report
Current view: top level - dashql-app/src/connections - hyper_connection_settings.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.9 % 279 276
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as connection from '@ankoh/dashql-jsonschema/connection.js';
       3              : import * as buf from '@bufbuild/protobuf';
       4              : import * as pb from '../proto.js';
       5              : import * as style from './connection_settings.module.css';
       6              : 
       7              : import { ChecklistIcon, DatabaseIcon, FileBadgeIcon, KeyIcon } from '../ui/foundations/symbol_icon.js';
       8              : 
       9              : import { classNames } from '../utils/classnames.js';
      10              : import {
      11              :     KeyValueTextField,
      12              :     TextField,
      13              :     TextFieldValidationStatus,
      14              :     VALIDATION_ERROR,
      15              :     VALIDATION_UNKNOWN,
      16              : } from '../ui/foundations/text_field.js';
      17              : import { useLogger } from '../platform/logger/logger_provider.js';
      18              : import { useHyperGrpcClient, useHyperHttpClient } from './hyper/hyperdb_grpc_client_provider.js';
      19              : import { flattenKeyValueList, KeyValueListBuilder, KeyValueListElement, UpdateKeyValueList } from '../ui/foundations/keyvalue_list.js';
      20              : import { Dispatch } from '../utils/variant.js';
      21              : import { useConnectionById, useConnectionDraftForm } from './connection_registry.js';
      22              : import { useConnectionSave } from './connection_persistence.js';
      23              : import { useConnectionSetup } from './connection_setup.js';
      24              : import { ConnectionHealth } from './connection_state.js';
      25              : import { performHealthCheck } from './health_check.js';
      26              : import { useHyperSetup } from './hyper/hyper_connection_setup.js';
      27              : import { useQueryExecutor } from './query_executor.js';
      28              : import { CONNECTOR_INFOS, ConnectorType } from './connector_info.js';
      29              : import { isDesktopHost } from '../platform/native_globals.js';
      30              : import { ConnectionInlineHeader } from './connection_inline_header.js';
      31              : import { HYPERDB_WASM_ENGINE_SETTINGS } from '../platform/hyperdb/hyperdb_settings.js';
      32              : import { DEFAULT_HYPER_ENDPOINT } from './connection_params.js';
      33              : import { isHyperWasmConnection } from './connection_label.js';
      34              : 
      35            1 : const LOG_CTX = "hyper_connector";
      36              : 
      37            1 : const HyperEngineArgumentPrefix: React.FC = () => <span className={style.engine_argument_token}>--</span>;
      38            1 : const HyperEngineArgumentDelimiter: React.FC = () => <span className={style.engine_argument_token}>=</span>;
      39              : 
      40            1 : export const HYPERDB_WASM_ENGINE_SETTING_ELEMENTS: readonly KeyValueListElement[] = Object.freeze(
      41            1 :     Object.entries(HYPERDB_WASM_ENGINE_SETTINGS).map(([key, value]) => Object.freeze({
      42            1 :         key,
      43            1 :         value: typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value),
      44            1 :     })),
      45            1 : );
      46              : 
      47              : export interface HyperConnectionPageState {
      48              :     protocol: connection.HyperProtocol;
      49              :     endpoint: string;
      50              :     mTlsKeyPath: string;
      51              :     mTlsPubPath: string;
      52              :     mTlsCaPath: string;
      53              :     attachedDatabases: KeyValueListElement[];
      54              :     gRPCMetadata: KeyValueListElement[];
      55              :     queryParameters: KeyValueListElement[];
      56              : };
      57              : 
      58            1 : export function buildHyperConnectionPageState(params: connection.HyperConnectionParams | undefined): HyperConnectionPageState {
      59            1 :     const metadata = params?.metadata ?? {};
      60            1 :     return {
      61            1 :         protocol: params?.protocol ?? "WASM",
      62            1 :         endpoint: params?.endpoint ?? DEFAULT_HYPER_ENDPOINT,
      63            1 :         mTlsKeyPath: params?.tls?.clientKeyPath ?? "",
      64            1 :         mTlsPubPath: params?.tls?.clientCertPath ?? "",
      65            1 :         mTlsCaPath: params?.tls?.caCertsPath ?? "",
      66            1 :         attachedDatabases: (params?.attachedDatabases ?? []).map((db: any) => ({
      67            1 :             key: db?.path ?? "",
      68            1 :             value: db?.alias ?? "",
      69            1 :         })),
      70            1 :         gRPCMetadata: params?.draftRows?.metadata ?? Object.entries(metadata).map(([k, v]) => ({ key: k, value: v ?? "" })),
      71            1 :         queryParameters: params?.draftRows?.queryParameters ?? Object.entries(params?.queryParameters ?? {}).map(([k, v]) => ({ key: k, value: v ?? "" })),
      72            1 :     };
      73            1 : }
      74              : 
      75            1 : export function buildHyperConnectionSetupParams(pageState: HyperConnectionPageState): connection.HyperConnectionParams {
      76            1 :     return {
      77            1 :         protocol: pageState.protocol,
      78            1 :         endpoint: pageState.endpoint,
      79            1 :         tls: {
      80            1 :             clientKeyPath: pageState.mTlsKeyPath,
      81            1 :             clientCertPath: pageState.mTlsPubPath,
      82            1 :             caCertsPath: pageState.mTlsCaPath,
      83            1 :         },
      84            1 :         attachedDatabases: pageState.attachedDatabases.map(v => buf.create(pb.salesforce_hyperdb_grpc_v1.pb.AttachedDatabaseSchema, {
      85            1 :             path: v.key,
      86            1 :             alias: v.value,
      87            1 :         })),
      88            1 :         metadata: flattenKeyValueList(pageState.gRPCMetadata),
      89            1 :         queryParameters: flattenKeyValueList(pageState.queryParameters),
      90            1 :     };
      91            1 : }
      92              : 
      93              : interface Props {
      94              :     connectionId: string | null;
      95              :     onClose?: () => void;
      96              : }
      97              : 
      98            1 : export const HyperConnectorSettings: React.FC<Props> = (props: Props) => {
      99            1 :     const logger = useLogger();
     100            1 :     const grpcClient = useHyperGrpcClient();
     101            1 :     const httpClient = useHyperHttpClient();
     102            1 :     const hyperSetup = useHyperSetup();
     103            1 :     const queryExecutor = useQueryExecutor();
     104              : 
     105              :     // Can we use the connector here?
     106            1 :     const connectorInfo = CONNECTOR_INFOS[ConnectorType.HYPER];
     107              : 
     108              :     // Wire up the page state
     109            1 :     const [connectionState, dispatchConnectionState] = useConnectionById(props.connectionId);
     110            1 :     const connectionSetup = useConnectionSetup(props.connectionId);
     111            1 :     const { saving } = useConnectionSave(props.connectionId);
     112              : 
     113            1 :     const [pageState, setPageState] = useConnectionDraftForm(props.connectionId,
     114            1 :         params => buildHyperConnectionPageState(params && 'hyper' in params ? params.hyper as connection.HyperConnectionParams : undefined),
     115            1 :         form => ({ hyper: { ...buildHyperConnectionSetupParams(form), draftRows: { metadata: form.gRPCMetadata, queryParameters: form.queryParameters } } }));
     116              : 
     117            1 :     const protocol = pageState.protocol;
     118              : 
     119            1 :     const wrongPlatform = protocol === "V3_GRPC" && !isDesktopHost();
     120              :     // The app holds a single connection to the embedded Hyper, connections don't switch between it and a server
     121            1 :     const embedded = connectionState != null && isHyperWasmConnection(connectionState);
     122              :     // A connection to the Hyper of a link takes its endpoint from the link, it doesn't switch to another protocol
     123            1 :     const linked = protocol === "V3_HTTP_LINK";
     124            1 :     const protocols: connection.HyperProtocol[] = embedded
     125            1 :         ? ["WASM"]
     126            1 :         : linked ? ["V3_HTTP_LINK"] : isDesktopHost() ? ["V3_GRPC", "V3_HTTP"] : ["V3_HTTP"];
     127            1 :     const setProtocol = (v: connection.HyperProtocol) => setPageState(s => ({ ...s, protocol: v }));
     128            1 :     const setEndpoint = (v: string) => setPageState(s => ({ ...s, endpoint: v }));
     129            1 :     const setMTLSKeyPath = (v: string) => setPageState(s => ({ ...s, mTlsKeyPath: v }));
     130            1 :     const setMTLSPubPath = (v: string) => setPageState(s => ({ ...s, mTlsPubPath: v }));
     131            1 :     const setMTLSCaPath = (v: string) => setPageState(s => ({ ...s, mTlsCaPath: v }));
     132            1 :     const modifyAttachedDbs: Dispatch<UpdateKeyValueList> = (action: UpdateKeyValueList) => setPageState(s => ({ ...s, attachedDatabases: action(s.attachedDatabases) }));
     133            1 :     const modifyGrpcMetadata: Dispatch<UpdateKeyValueList> = (action: UpdateKeyValueList) => setPageState(s => ({ ...s, gRPCMetadata: action(s.gRPCMetadata) }));
     134            1 :     const modifyQueryParameters: Dispatch<UpdateKeyValueList> = (action: UpdateKeyValueList) => setPageState(s => ({ ...s, queryParameters: action(s.queryParameters) }));
     135            1 :     const isGrpc = protocol === "V3_GRPC";
     136            1 :     const [clientIdentityValidation, setClientIdentityValidation] = React.useState<TextFieldValidationStatus>({
     137            1 :         type: VALIDATION_UNKNOWN,
     138            1 :         value: null,
     139            1 :     });
     140            1 :     const [endpointValidation, setEndpointValidation] = React.useState<TextFieldValidationStatus>({
     141            1 :         type: VALIDATION_UNKNOWN,
     142            1 :         value: null,
     143            1 :     });
     144              : 
     145            1 :     React.useEffect(() => {
     146            1 :         setClientIdentityValidation({ type: VALIDATION_UNKNOWN, value: null });
     147            1 :         setEndpointValidation({ type: VALIDATION_UNKNOWN, value: null });
     148            1 :     }, [connectionState?.paramsDraft, pageState.protocol, pageState.endpoint, pageState.mTlsKeyPath, pageState.mTlsPubPath, pageState.mTlsCaPath]);
     149              : 
     150              :     // Helper to setup the connection
     151            1 :     const setupParams = React.useMemo<connection.HyperConnectionParams>(() => buildHyperConnectionSetupParams(pageState), [pageState]);
     152            1 :     const setupConnection = async () => {
     153            1 :         const hasTlsPaths = Boolean(pageState.mTlsKeyPath || pageState.mTlsPubPath || pageState.mTlsCaPath);
     154            1 :         let isHttps = false;
     155            1 :         try {
     156            1 :             isHttps = new URL(pageState.endpoint).protocol === "https:";
     157            1 :         } catch {
     158              :             // Endpoint parsing and error reporting remain part of connection setup.
     159            1 :         }
     160            1 :         if (isGrpc && hasTlsPaths && !isHttps) {
     161            1 :             setEndpointValidation({
     162            1 :                 type: VALIDATION_ERROR,
     163            1 :                 value: "TLS certificate paths require an https:// endpoint",
     164            1 :             });
     165            1 :             return;
     166            1 :         }
     167            1 :         if (isGrpc && Boolean(pageState.mTlsKeyPath) !== Boolean(pageState.mTlsPubPath)) {
     168            1 :             setClientIdentityValidation({
     169            1 :                 type: VALIDATION_ERROR,
     170            1 :                 value: "Client key and certificate paths must both be provided",
     171            1 :             });
     172            1 :             return;
     173            1 :         }
     174            1 :         setClientIdentityValidation({ type: VALIDATION_UNKNOWN, value: null });
     175              : 
     176              :         // Is there a Hyper client?
     177            1 :         if ((protocol !== 'WASM' && !grpcClient && !httpClient) || hyperSetup == null) {
     178            1 :             logger.error("Hyper connector is unavailable", {}, LOG_CTX);
     179            1 :             return;
     180            1 :         }
     181              :         // Is there a connection id?
     182            1 :         if (connectionState == null) {
     183            0 :             logger.warn("Connection state is null", {}, LOG_CTX);
     184            0 :             return;
     185            0 :         }
     186              : 
     187            1 :         await connectionSetup.run(async signal => {
     188            1 :             try {
     189            1 :                 const hyperChannel = await hyperSetup.setup(dispatchConnectionState, setupParams, signal, connectionState.connectionId);
     190            1 :                 if (hyperChannel != null && protocol !== 'WASM') {
     191            1 :                     await performHealthCheck(queryExecutor, connectionState.connectionId, { type: 'hyper', channel: hyperChannel }, dispatchConnectionState, signal);
     192              :                 }
     193            1 :             } catch {
     194              :                 // The setup and the health check update the connection state with the failure details.
     195            1 :             }
     196            1 :         });
     197            1 :     };
     198            1 :     const cancelSetup = connectionSetup.cancel;
     199            1 :     const resetSetup = async () => {
     200            1 :         if (hyperSetup) {
     201            1 :             await hyperSetup.reset(dispatchConnectionState);
     202              :         }
     203            1 :     };
     204              : 
     205              :     // Get the action button
     206              :     // Saving writes the settings into every session that uses the connection
     207            1 :     let freezeInput = saving;
     208            1 :     switch (connectionState?.connectionHealth) {
     209            1 :         case ConnectionHealth.NOT_STARTED:
     210            1 :         case ConnectionHealth.CANCELLED:
     211            1 :         case ConnectionHealth.FAILED:
     212            1 :             break;
     213            1 :         case ConnectionHealth.CONNECTING:
     214            1 :             freezeInput = true;
     215            1 :             break;
     216            1 :         case ConnectionHealth.ONLINE:
     217              :             break;
     218            1 :     }
     219              : 
     220            1 :     return (
     221            1 :         <div className={style.layout}>
     222            1 :             <ConnectionInlineHeader
     223            1 :                 connector={connectorInfo}
     224            1 :                 connection={connectionState}
     225            1 :                 wrongPlatform={wrongPlatform}
     226            1 :                 setupConnection={setupConnection}
     227            1 :                 cancelSetup={cancelSetup}
     228            1 :                 resetSetup={resetSetup}
     229            1 :                 protocol={protocol}
     230            1 :                 protocols={protocols}
     231            1 :                 onProtocolChange={setProtocol}
     232            1 :                 freezeInput={freezeInput}
     233            1 :                 embedded={protocol === 'WASM'}
     234            1 :                 onClose={props.onClose}
     235            1 :             />
     236            1 :             {protocol === 'WASM' ? (
     237            1 :                 <div className={style.body_container}>
     238            1 :                     <div className={style.section}>
     239            1 :                         <div className={`${style.section_layout} ${style.body_section_layout}`}>
     240            1 :                             <KeyValueListBuilder
     241            1 :                                 className={style.grid_column_1_span_2}
     242            1 :                                 title="Engine Settings"
     243            1 :                                 caption="Settings used to initialize the embedded Hyper engine"
     244            1 :                                 keyIcon={HyperEngineArgumentPrefix}
     245            1 :                                 valueIcon={HyperEngineArgumentDelimiter}
     246            1 :                                 addButtonLabel="Add Setting"
     247            1 :                                 elements={HYPERDB_WASM_ENGINE_SETTING_ELEMENTS}
     248            1 :                                 modifyElements={() => { }}
     249            1 :                                 disabled
     250            1 :                                 readOnly
     251            1 :                                 sideBySide
     252            1 :                                 copyValue={({ key, value }) => `--${key}=${value}`}
     253            1 :                                 copyLogContext={LOG_CTX}
     254            1 :                             />
     255            1 :                         </div>
     256            1 :                     </div>
     257            1 :                 </div>
     258              :             ) : (
     259            1 :                 <div className={style.body_container}>
     260            1 :                     <div className={style.section}>
     261            1 :                         <div className={classNames(style.section_layout, style.body_section_layout)}>
     262            1 :                             {linked ? (
     263            1 :                                 <TextField
     264            1 :                                     name="HTTP Endpoint"
     265            1 :                                     caption="Endpoint of the link that the app was opened with, e.g. by 'hyperd run --ui'"
     266            1 :                                     value={pageState.endpoint}
     267            1 :                                     placeholder="Open a link to connect"
     268            1 :                                     leadingVisual={() => <div>URL</div>}
     269            1 :                                     onChange={() => { }}
     270            1 :                                     disabled
     271            1 :                                     readOnly
     272            1 :                                     logContext={LOG_CTX}
     273            1 :                                 />
     274              :                             ) : (
     275            1 :                                 <TextField
     276            1 :                                     name={isGrpc ? "gRPC Endpoint" : "HTTP Endpoint"}
     277            1 :                                     caption={isGrpc
     278            1 :                                         ? "Endpoint of the gRPC service as 'https://host:port'"
     279            1 :                                         : "Endpoint of the HTTP service as 'https://host:port'"
     280              :                                     }
     281            1 :                                     value={pageState.endpoint}
     282            1 :                                     placeholder={isGrpc ? "gRPC endpoint url" : "HTTP endpoint url"}
     283            1 :                                     leadingVisual={() => <div>URL</div>}
     284            1 :                                     onChange={(e) => setEndpoint(e.target.value)}
     285            1 :                                     disabled={freezeInput}
     286            1 :                                     readOnly={freezeInput}
     287            1 :                                     validation={endpointValidation}
     288            1 :                                     logContext={LOG_CTX}
     289            1 :                                 />
     290              :                             )}
     291            1 :                             {isGrpc && <KeyValueTextField
     292            1 :                                 className={style.grid_column_1}
     293            1 :                                 name="mTLS Client Key"
     294            1 :                                 caption="Paths to client key and client certificate"
     295            1 :                                 k={pageState.mTlsKeyPath}
     296            1 :                                 v={pageState.mTlsPubPath}
     297            1 :                                 keyPlaceholder="client.key"
     298            1 :                                 valuePlaceholder="client.pem"
     299            1 :                                 keyIcon={KeyIcon}
     300            1 :                                 valueIcon={FileBadgeIcon}
     301            1 :                                 onChangeKey={(e) => setMTLSKeyPath(e.target.value)}
     302            1 :                                 onChangeValue={(e) => setMTLSPubPath(e.target.value)}
     303            1 :                                 keyAriaLabel='mTLS Client Key'
     304            1 :                                 valueAriaLabel='mTLS Client Certificate'
     305            1 :                                 logContext={LOG_CTX}
     306            1 :                                 validation={clientIdentityValidation}
     307            1 :                                 disabled={freezeInput}
     308            1 :                                 readOnly={freezeInput}
     309            1 :                             />}
     310            1 :                             {isGrpc && <TextField
     311            1 :                                 name="mTLS CA certificates"
     312            1 :                                 caption="Path to certificate authority (CA) certificates"
     313            1 :                                 value={pageState.mTlsCaPath}
     314            1 :                                 placeholder="cacerts.pem"
     315            1 :                                 leadingVisual={ChecklistIcon}
     316            1 :                                 onChange={(e) => setMTLSCaPath(e.target.value)}
     317            1 :                                 logContext={LOG_CTX}
     318            1 :                                 disabled={freezeInput}
     319            1 :                                 readOnly={freezeInput}
     320            1 :                             />}
     321            1 :                         </div>
     322            1 :                     </div>
     323            1 :                     <div className={style.section}>
     324            1 :                         <div className={classNames(style.section_layout, style.body_section_layout)}>
     325            1 :                             <KeyValueListBuilder
     326            1 :                                 className={style.grid_column_1}
     327            1 :                                 title="Attached Databases"
     328            1 :                                 caption="Databases that are attached for every query"
     329            1 :                                 keyIcon={DatabaseIcon}
     330            1 :                                 valueIcon={() => <div>ID</div>}
     331            1 :                                 addButtonLabel="Add Database"
     332            1 :                                 elements={pageState.attachedDatabases}
     333            1 :                                 modifyElements={modifyAttachedDbs}
     334            1 :                                 disabled={freezeInput}
     335            1 :                                 readOnly={freezeInput}
     336            1 :                             />
     337            1 :                             <KeyValueListBuilder
     338            1 :                                 title={isGrpc ? "gRPC Metadata" : "HTTP Headers"}
     339            1 :                                 caption="Extra HTTP headers that are added to each request"
     340            1 :                                 keyIcon={() => <div>Header</div>}
     341            1 :                                 valueIcon={() => <div>Value</div>}
     342            1 :                                 addButtonLabel="Add Header"
     343            1 :                                 elements={pageState.gRPCMetadata}
     344            1 :                                 modifyElements={modifyGrpcMetadata}
     345            1 :                                 disabled={freezeInput}
     346            1 :                                 readOnly={freezeInput}
     347            1 :                             />
     348            1 :                             <KeyValueListBuilder
     349            1 :                                 title="Query Settings"
     350            1 :                                 caption="Attached database settings that are added to every query"
     351            1 :                                 keyIcon={() => <div>Name</div>}
     352            1 :                                 valueIcon={() => <div>Value</div>}
     353            1 :                                 addButtonLabel="Add Setting"
     354            1 :                                 elements={pageState.queryParameters}
     355            1 :                                 modifyElements={modifyQueryParameters}
     356            1 :                                 disabled={freezeInput}
     357            1 :                                 readOnly={freezeInput}
     358            1 :                             />
     359            1 :                         </div>
     360            1 :                     </div>
     361            1 :                 </div>
     362              :             )}
     363            1 :         </ div>
     364              :     );
     365            1 : };
        

Generated by: LCOV version 2.0-1