Repository: temporalio/ui
Pull request #206 (2022-01-11 00:10:06)

Queries
Implements the queries page. Ports over this [piece of functionality from Web v1](https://github.com/temporalio/web/blob/fa48bb3380535d2fca50e285932aa21d26a10354/server/routes.js#L164-L189).


Patch:
diff --git a/src/lib/components/button.svelte b/src/lib/components/button.svelte
index 39def810..cd63aaa6 100644
--- a/src/lib/components/button.svelte
+++ b/src/lib/components/button.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
   import Icon from 'svelte-fa';
   import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+  import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
 
   export let disabled: boolean = false;
   export let secondary: boolean = false;
@@ -9,7 +10,8 @@
   export let as: string = 'button';
   export let active: boolean = false;
   export let large: boolean = false;
-  export let href: string | null = null;
+  export let href: string = null;
+  export let icon: IconDefinition = null;
 </script>
 
 {#if as === 'button'}
@@ -21,11 +23,13 @@
     class:secondary
     class:destroy
     {disabled}
-    >{#if loading}
-      <span class="animate-spin"> <Icon icon={faSpinner} scale={1} /></span>
-    {:else}
-      <slot />
+  >
+    {#if icon}
+      <span class:animate-spin={loading} class="pr-2">
+        <Icon icon={loading ? faSpinner : icon} scale={1} />
+      </span>
     {/if}
+    <slot />
   </button>
 {:else}
   <a
@@ -38,7 +42,7 @@
     class:destroy
     {disabled}
     >{#if loading}
-      <span class="animate-spin"> <Icon icon={faSpinner} scale={1} /></span>
+      <span class="animate-spin"> <Icon {icon} scale={1} /></span>
     {:else}
       <slot />
     {/if}</a
diff --git a/src/lib/services/query-service.ts b/src/lib/services/query-service.ts
index 6fe17aee..4dcf926c 100644
--- a/src/lib/services/query-service.ts
+++ b/src/lib/services/query-service.ts
@@ -1,16 +1,20 @@
-import sanitize from 'sanitize-html';
-
-import { requestFromAPI } from '$lib/utilities/request-from-api';
+import {
+  isTemporalAPIError,
+  requestFromAPI,
+} from '$lib/utilities/request-from-api';
 import { routeForApi } from '$lib/utilities/route-for-api';
 
-import type { WorkflowExecution } from '$lib/models/workflow-execution';
 import type { WorkflowRouteParameters } from '$lib/utilities/route-for-api';
+import { getQueryTypesFromError } from '$lib/utilities/get-query-types-from-error';
 
-type StackTraceOptions = {
-  workflow: Eventual<WorkflowExecution>;
+type QueryRequestParameters = {
+  workflow: Eventual<{ id: string; runId: string }>;
   namespace: string;
+  queryType: string;
 };
 
+type WorkflowParameters = Omit<QueryRequestParameters, 'queryType'>;
+
 type QueryPayload = {
   data: string;
   metadata: {
@@ -22,14 +26,16 @@ type QueryType = {
   payloads: QueryPayload[];
 };
 
-type StackTraceExecution = {
+type QueryResponse = {
   queryRejected: string | null;
   queryResult: QueryType;
 };
 
+type ParsedQuery = ReturnType<typeof JSON.parse>[0];
+
 const formatParameters = async (
   namespace: string,
-  workflow: Eventual<WorkflowExecution>,
+  workflow: Eventual<{ id: string; runId: string }>,
 ): Promise<WorkflowRouteParameters> => {
   workflow = await workflow;
   return {
@@ -39,36 +45,68 @@ const formatParameters = async (
   };
 };
 
-const formatStackTrace = (data: string): string => {
-  return sanitize(data).slice(1, data.length - 1);
-};
-
-export async function getWorkflowStackTrace({
-  workflow,
-  namespace,
-}: StackTraceOptions): Promise<string> {
+async function fetchQuery(
+  { workflow, namespace, queryType }: QueryRequestParameters,
+  request = fetch,
+  onError?: (error: {
+    status: number;
+    statusText: string;
+    body: unknown;
+  }) => void,
+): Promise<QueryResponse> {
   workflow = await workflow;
   const parameters = await formatParameters(namespace, workflow);
 
-  return await requestFromAPI<StackTraceExecution>(
-    routeForApi('query', parameters),
-    {
-      options: {
-        method: 'POST',
-        body: JSON.stringify({
-          execution: {
-            workflowId: workflow.id,
-            runId: workflow.runId,
-          },
-          query: {
-            queryType: '__stack_trace',
-          },
-        }),
-      },
-      shouldRetry: false,
+  return await requestFromAPI<QueryResponse>(routeForApi('query', parameters), {
+    options: {
+      method: 'POST',
+      body: JSON.stringify({
+        execution: {
+          workflowId: workflow.id,
+          runId: workflow.runId,
+        },
+        query: {
+          queryType,
+        },
+      }),
     },
-  ).then((execution) => {
+    request,
+    onError,
+  });
+}
+
+export async function getQueryTypes(
+  options: WorkflowParameters,
+  request = fetch,
+): Promise<string[]> {
+  return new Promise<string[]>((resolve, reject) => {
+    fetchQuery(
+      { ...options, queryType: '@@temporal-internal__list' },
+      request,
+      ({ body }) => {
+        if (isTemporalAPIError(body)) {
+          resolve(getQueryTypesFromError(body.message));
+        }
+      },
+    );
+  });
+}
+
+export async function getQuery(
+  options: QueryRequestParameters,
+  request = fetch,
+): Promise<ParsedQuery> {
+  return fetchQuery(options, request).then((execution) => {
     const { queryResult } = execution;
-    return formatStackTrace(window.atob(queryResult.payloads[0].data));
+    const data = window.atob(queryResult.payloads[0].data);
+
+    return JSON.parse(data);
   });
 }
+
+export async function getWorkflowStackTrace(
+  options: WorkflowParameters,
+  request = fetch,
+): Promise<ParsedQuery> {
+  return getQuery({ ...options, queryType: '__stack_trace' }, request);
+}
diff --git a/src/lib/utilities/get-query-types-from-error.ts b/src/lib/utilities/get-query-types-from-error.ts
new file mode 100644
index 00000000..a0f7eb85
--- /dev/null
+++ b/src/lib/utilities/get-query-types-from-error.ts
@@ -0,0 +1,9 @@
+export const getQueryTypesFromError = (message: string): string[] => {
+  const indexOfOpeningBracket = message.indexOf('[');
+  const indexOfClosingBracket = message.indexOf(']');
+
+  return message
+    .slice(indexOfOpeningBracket + 1, indexOfClosingBracket)
+    .split(' ')
+    .filter((query) => query !== '__stack_trace');
+};
diff --git a/src/lib/utilities/request-from-api.ts b/src/lib/utilities/request-from-api.ts
index 5db1329d..fe3bac08 100644
--- a/src/lib/utilities/request-from-api.ts
+++ b/src/lib/utilities/request-from-api.ts
@@ -1,17 +1,35 @@
 import { browser } from '$app/env';
 import { handleError } from './handle-error';
+import { isFunction } from './is-function';
 import { toURL } from './to-url';
 
+type TemporalAPIError = {
+  code: number;
+  message: string;
+  details: unknown[];
+};
+
+type ErrorCallback = (error: {
+  status: number;
+  statusText: string;
+  body: TemporalAPIError;
+}) => void;
+
 type toURLParams = Parameters<typeof toURL>;
 type RequestFromAPIOptions = {
   params?: toURLParams[1];
   request?: typeof fetch;
   options?: Parameters<typeof fetch>[1];
   token?: string;
+  onError?: ErrorCallback;
   shouldRetry?: boolean;
   retryInterval?: number;
 };
 
+export const isTemporalAPIError = (obj: unknown): obj is TemporalAPIError =>
+  (obj as TemporalAPIError).message !== undefined &&
+  typeof (obj as TemporalAPIError).message === 'string';
+
 /**
  *  A utility method for making requests to the Temporal API.
  *
@@ -35,6 +53,7 @@ export const requestFromAPI = async <T>(
     request = fetch,
     token,
     shouldRetry = false,
+    onError,
     retryInterval = 5000,
   } = init;
   let { options } = init;
@@ -50,12 +69,19 @@ export const requestFromAPI = async <T>(
     options = withSecurityOptions(options);
 
     const response = await request(url, options);
+    const body = await response.json();
+
+    const { status, statusText } = response;
 
     if (!response.ok) {
-      throw new Error(`${response.status}: ${response.statusText}`);
+      if (onError && isFunction(onError)) {
+        onError({ status, statusText, body });
+      } else {
+        throw new Error(`${status}: ${statusText}`);
+      }
     }
 
-    return await response.json();
+    return body;
   } catch (error: unknown) {
     handleError(error);
 
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/queries.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/queries.svelte
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/query.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/query.svelte
new file mode 100644
index 00000000..0c73a68a
--- /dev/null
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/query.svelte
@@ -0,0 +1,68 @@
+<script context="module" lang="ts">
+  import type { LoadInput } from '@sveltejs/kit';
+  import { getQuery, getQueryTypes } from '$lib/services/query-service';
+
+  export async function load({ page, fetch: request }: LoadInput) {
+    const { namespace, workflow: id, run: runId } = page.params;
+    const workflow = { id, runId };
+
+    const queryTypes = await getQueryTypes({ namespace, workflow });
+
+    const queryTypeParameter = page.query.get('query-type');
+    const queryType = queryTypes.includes(queryTypeParameter)
+      ? queryTypeParameter
+      : queryTypes[0];
+
+    page.query.set('query-type', queryType);
+
+    const queryResult = await getQuery(
+      { namespace, workflow, queryType },
+      request,
+    );
+
+    return {
+      props: {
+        namespace,
+        workflow,
+        queryType,
+        queryTypes,
+        queryResult,
+      },
+    };
+  }
+</script>
+
+<script lang="ts">
+  import { faPlay } from '@fortawesome/free-solid-svg-icons';
+
+  import CodeBlock from '$lib/components/code-block.svelte';
+  import Button from '$lib/components/button.svelte';
+  import FilterSelect from '$lib/components/select/filter-select.svelte';
+  import Option from '$lib/components/select/option.svelte';
+
+  export let namespace: string;
+  export let workflow: { id: string; runId: string };
+  export let queryTypes: string[];
+  export let queryType: string;
+  export let queryResult: string;
+
+  const fetchQuery = async () => {
+    queryResult = await getQuery({ namespace, workflow, queryType });
+  };
+
+  $: console.log(queryResult);
+</script>
+
+<section>
+  <div class="flex items-center gap-4">
+    <FilterSelect label="Query Type" value={queryType} parameter="query-type">
+      {#each queryTypes as value}
+        <Option {value}>{value}</Option>
+      {/each}
+    </FilterSelect>
+    <Button icon={faPlay} on:click={fetchQuery}>Run</Button>
+  </div>
+  <div class="flex items-start h-full">
+    <CodeBlock content={queryResult} />
+  </div>
+</section>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/stack-trace.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/stack-trace.svelte
index 5bd7ecf0..7a3dc343 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/stack-trace.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/stack-trace.svelte
@@ -1,51 +1,66 @@
+<script context="module" lang="ts">
+  import type { LoadInput } from '@sveltejs/kit';
+
+  export async function load({ page }: LoadInput) {
+    const { namespace, workflow: id, run: runId } = page.params;
+    const workflow = { id, runId };
+
+    const stackTrace = getWorkflowStackTrace({
+      workflow,
+      namespace,
+    });
+
+    return {
+      props: {
+        namespace,
+        stackTrace,
+      },
+    };
+  }
+</script>
+
 <script lang="ts">
   import { getContext } from 'svelte';
-  import Icon from 'svelte-fa';
   import { faRedo } from '@fortawesome/free-solid-svg-icons';
 
   import type { WorkflowExecution } from '$lib/models/workflow-execution';
 
-  import { namespace } from '$lib/stores/namespace';
   import { getWorkflowStackTrace } from '$lib/services/query-service';
 
   import CodeBlock from '$lib/components/code-block.svelte';
   import Button from '$lib/components/button.svelte';
   import EmptyState from '$lib/components/empty-state.svelte';
 
-  let workflow = getContext<PromiseLike<WorkflowExecution>>('workflow');
+  export let namespace: string;
+  export let stackTrace: string;
 
+  let workflow = getContext<PromiseLike<WorkflowExecution>>('workflow');
   let currentdate = new Date();
-  let isLoading = true;
-
-  $: datetime = currentdate.toLocaleTimeString();
-  $: data = getWorkflowStackTrace({ workflow, namespace: $namespace });
+  let isLoading = false;
 
-  $: {
+  const refreshStackTrace = async () => {
     isLoading = true;
-    currentdate = new Date();
-    data.then(() => {
-      isLoading = false;
-      new Date();
+
+    stackTrace = await getWorkflowStackTrace({
+      workflow,
+      namespace,
     });
-  }
 
-  const refreshStackTrace = () => {
-    data = getWorkflowStackTrace({ workflow, namespace: $namespace });
-    isLoading = true;
+    isLoading = false;
+    currentdate = new Date();
   };
 </script>
 
 {#await workflow then workflow}
   <section>
     {#if String(workflow.status) === 'Running'}
-      <div class="flex items-center">
-        <Button on:click={refreshStackTrace} loading={isLoading}>
-          <span> <Icon icon={faRedo} scale={0.8} /></span>
+      <div class="flex items-center gap-4">
+        <Button on:click={refreshStackTrace} icon={faRedo} loading={isLoading}>
           Refresh
         </Button>
-        <p>Stack Trace at {datetime}</p>
+        <p>Stack Trace at {currentdate.toLocaleTimeString()}</p>
       </div>
-      {#await data then result}
+      {#await stackTrace then result}
         <div class="flex items-start h-full">
           <CodeBlock content={result} language="text" />
         </div>
@@ -55,13 +70,3 @@
     {/if}
   </section>
 {/await}
-
-<style lang="postcss">
-  p {
-    @apply ml-2;
-  }
-
-  span {
-    @apply mr-1;
-  }
-</style>
diff --git a/src/routes/namespaces/[namespace]/workflows/index.svelte b/src/routes/namespaces/[namespace]/workflows/index.svelte
index 84810682..1eb4ed64 100644
--- a/src/routes/namespaces/[namespace]/workflows/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/index.svelte
@@ -24,7 +24,9 @@
 
 <script lang="ts">
   import { page } from '$app/stores';
+
   import { fetchAllWorkflows } from '$lib/services/workflow-service';
+  import { refreshable } from '$lib/stores/refreshable';
 
   import WorkflowsSummaryTable from './_workflows-summary-table.svelte';
   import WorkflowsSummaryRow from './_workflows-summary-row.svelte';
@@ -32,7 +34,6 @@
   import EmptyState from '$lib/components/empty-state.svelte';
   import WorkflowsLoadingState from './_workflows-loading.svelte';
   import VirtualList from '@sveltejs/svelte-virtual-list';
-  import { refreshable } from '$lib/stores/refreshable';
 
   export let namespace: string;
   export let initialData: ReturnType<typeof fetchAllWorkflows>;


Test patch:
diff --git a/src/lib/utilities/get-query-types-from-error.test.ts b/src/lib/utilities/get-query-types-from-error.test.ts
new file mode 100644
index 00000000..e240801e
--- /dev/null
+++ b/src/lib/utilities/get-query-types-from-error.test.ts
@@ -0,0 +1,27 @@
+import { getQueryTypesFromError } from './get-query-types-from-error';
+
+const error = {
+  code: 3,
+  message:
+    'unknown queryType @@temporal-internal__list. KnownQueryTypes=[__stack_trace __open_sessions]',
+  details: [
+    {
+      typeUrl:
+        'type.googleapis.com/temporal.api.errordetails.v1.QueryFailedFailure',
+      value: null,
+    },
+  ],
+};
+
+describe(getQueryTypesFromError, () => {
+  it('should return an array of query types', () => {
+    const queryTypes = ['__open_sessions'];
+    expect(getQueryTypesFromError(error.message)).toEqual(queryTypes);
+  });
+
+  it('should omit __stack_traces', () => {
+    expect(getQueryTypesFromError(error.message)).not.toContain(
+      '__stack_trace',
+    );
+  });
+});