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 : };
|