Repository: temporalio/ui
Pull request #230 (2022-01-14 01:48:47)

Make history export work for replay
<!--- Note to EXTERNAL Contributors -->
<!-- Thanks for opening a PR! 
If it is a significant code change, please **make sure there is an open issue** for this. 
We work best with you when we have accepted the idea first before you code. -->

<!--- For ALL Contributors 👇 -->

## What was changed
<!-- Describe what has changed in this PR -->

- Fixes Export History button 
- Changes to export raw history instead of dataconverted

![image](https://user-images.githubusercontent.com/11838981/149437650-f5aee06d-658a-4b17-92d3-c00e5083ffe2.png)

## Why?
<!-- Tell your future self why have you made these changes -->

- helpful at history replay

## Checklist
<!--- add/delete as needed --->

1. Closes <!-- add issue number here -->

2. How was this tested:
<!--- Please describe how you tested your changes/how we can test them -->
 
3. Any docs updates needed?
<!--- update README if applicable
      or point out where to update docs.temporal.io -->



Patch:
diff --git a/.github/workflows/trigger-ui-server-update.yml b/.github/workflows/trigger-ui-server-update.yml
index 4db3a6a3..3a79ed52 100644
--- a/.github/workflows/trigger-ui-server-update.yml
+++ b/.github/workflows/trigger-ui-server-update.yml
@@ -18,9 +18,9 @@ jobs:
     steps:
       - name: Dispatch ui-server Github Action
         env:
-          PAT: ${{ secrets.PAT }}
+          PAT: ${{ secrets.COMMANDER_DATA_TOKEN }}
           PARENT_REPO: temporalio/ui-server
           PARENT_BRANCH: main
           WORKFLOW_ID: update-ui.yml
         run: |
-          curl -fL -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ env.COMMANDER_DATA_TOKEN }}" https://api.github.com/repos/${{ env.PARENT_REPO }}/actions/workflows/${{ env.WORKFLOW_ID }}/dispatches -d '{"ref":"${{ env.PARENT_BRANCH }}"}'
+          curl -fL -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ env.PAT }}" https://api.github.com/repos/${{ env.PARENT_REPO }}/actions/workflows/${{ env.WORKFLOW_ID }}/dispatches -d '{"ref":"${{ env.PARENT_BRANCH }}"}'
diff --git a/src/lib/components/export-history.svelte b/src/lib/components/export-history.svelte
new file mode 100644
index 00000000..28f2bfc6
--- /dev/null
+++ b/src/lib/components/export-history.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+  import ToggleButton from '$lib/components/toggle-button.svelte';
+  import { fetchRawEvents } from '$lib/services/events-service';
+  import { faDownload } from '@fortawesome/free-solid-svg-icons';
+
+  const exportHistory = async () => {
+    const events = await fetchRawEvents({
+      namespace,
+      executionId: workflowId,
+      runId: runId,
+    });
+
+    const content = JSON.stringify(events, null, 2);
+    download(content, `${runId}/events.json`, 'text/plain');
+
+    function download(content: string, fileName: string, contentType: string) {
+      const a = document.createElement('a');
+      const file = new Blob([content], { type: contentType });
+      a.href = URL.createObjectURL(file);
+      a.download = fileName;
+      a.click();
+    }
+  };
+
+  export let namespace: string;
+  export let workflowId: string;
+  export let runId: string;
+</script>
+
+<ToggleButton icon={faDownload} onclick={exportHistory} href="#" />
diff --git a/src/lib/components/toggle-button.svelte b/src/lib/components/toggle-button.svelte
index 163019c3..778fc54a 100644
--- a/src/lib/components/toggle-button.svelte
+++ b/src/lib/components/toggle-button.svelte
@@ -10,6 +10,7 @@
   export let group = getContext<boolean>('group');
   export let scale = 1;
   export let href: string;
+  export let onclick: () => void = undefined;
 </script>
 
 <a
@@ -18,6 +19,7 @@
   class:active={$page.path.includes(href)}
   class:group
   href={appendQueryParameters(href, $page.query)}
+  on:click={onclick}
 >
   <Icon {icon} {scale} />
 </a>
diff --git a/src/lib/services/events-service.ts b/src/lib/services/events-service.ts
index affe492a..d2cb1ba7 100644
--- a/src/lib/services/events-service.ts
+++ b/src/lib/services/events-service.ts
@@ -51,3 +51,23 @@ export const fetchEvents = async (
 
   return events;
 };
+
+export const fetchRawEvents = async (
+  { namespace, executionId, runId, onStart, onUpdate, onComplete }: FetchEvents,
+  request = fetch,
+): Promise<HistoryEvent[]> => {
+  const resp = await paginated(
+    async (token: string) => {
+      return requestFromAPI<GetWorkflowExecutionHistoryResponse>(
+        routeForApi('events', { namespace, executionId, runId }),
+        {
+          token,
+          request,
+        },
+      );
+    },
+    { onStart, onUpdate, onComplete },
+  );
+
+  return resp.history.events;
+};
diff --git a/src/lib/stores/banner.ts b/src/lib/stores/banner.ts
index f4b4a8e7..02a3218e 100644
--- a/src/lib/stores/banner.ts
+++ b/src/lib/stores/banner.ts
@@ -1,4 +1,4 @@
-import { persistStore } from '$lib/utilities/persist-store';
+import { persistStore } from '$lib/stores/persist-store';
 
 export const closedBannerId = persistStore('closedBannerId', null);
 
diff --git a/src/lib/stores/data-converter-config.ts b/src/lib/stores/data-converter-config.ts
index d7bb91cb..f509bbfd 100644
--- a/src/lib/stores/data-converter-config.ts
+++ b/src/lib/stores/data-converter-config.ts
@@ -1,5 +1,5 @@
 import { writable } from 'svelte/store';
-import { persistStore } from '$lib/utilities/persist-store';
+import { persistStore } from '$lib/stores/persist-store';
 
 export const dataConverterPort = persistStore('port', null);
 
diff --git a/src/lib/utilities/create-data-url.ts b/src/lib/utilities/create-data-url.ts
deleted file mode 100644
index b506a4b7..00000000
--- a/src/lib/utilities/create-data-url.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export const createDataUrl = (
-  events: Parameters<typeof JSON.stringify>[0],
-): string => {
-  const data = JSON.stringify(events);
-  const dataUri =
-    'data:application/json;charset=utf-8,' + encodeURIComponent(data);
-
-  return dataUri;
-};
diff --git a/src/lib/stores/data-converter-websocket.ts b/src/lib/utilities/data-converter-websocket.ts
similarity index 95%
rename from src/lib/stores/data-converter-websocket.ts
rename to src/lib/utilities/data-converter-websocket.ts
index 498dbc38..b1b5ba56 100644
--- a/src/lib/stores/data-converter-websocket.ts
+++ b/src/lib/utilities/data-converter-websocket.ts
@@ -3,7 +3,7 @@ import WebSocketAsPromised from 'websocket-as-promised';
 import {
   dataConverterPort,
   setLastDataConverterFailure,
-} from './data-converter-config';
+} from '../stores/data-converter-config';
 
 let sock = null;
 const port = get(dataConverterPort) ?? null;
diff --git a/src/lib/utilities/decode-payload-lazy.ts b/src/lib/utilities/decode-payload-lazy.ts
new file mode 100644
index 00000000..8938fdcd
--- /dev/null
+++ b/src/lib/utilities/decode-payload-lazy.ts
@@ -0,0 +1,23 @@
+import type { Payload } from '$types';
+
+export function decodePayload(
+  payload: Payload,
+  // This could decode to any object. So we either use the payload object passed in or decode it
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): Payload | Record<any, any> | string {
+  const encoding = window.atob(String(payload.metadata.encoding));
+  // Help users out with an english encoding
+  (payload.metadata.encodingDecrypted as unknown as string) = encoding;
+
+  switch (encoding) {
+    case 'json/plain':
+    case 'json/protobuf':
+      try {
+        return JSON.parse(window.atob(String(payload.data)));
+      } catch (_e) {
+        // Couldn't correctly decode this just let the user deal with the data as is
+      }
+  }
+
+  return payload;
+}
diff --git a/src/lib/utilities/decode-payload.ts b/src/lib/utilities/decode-payload.ts
index 3445820d..d46b3dac 100644
--- a/src/lib/utilities/decode-payload.ts
+++ b/src/lib/utilities/decode-payload.ts
@@ -1,8 +1,9 @@
 import type { Payload } from '$types';
 import { dataConverterPort } from '$lib/stores/data-converter-config';
-import { dataConverterWebsocket } from '$lib/stores/data-converter-websocket';
+import { dataConverterWebsocket } from '$lib/utilities/data-converter-websocket';
 import { get } from 'svelte/store';
 import { convertPayload } from '$lib/services/data-converter';
+import { decodePayload } from './decode-payload-lazy';
 
 export const convertPayloadToJson = async (
   eventAttribute: EventAttribute,
@@ -26,13 +27,7 @@ export const convertPayloadToJson = async (
     return Promise.resolve(eventAttribute);
   }
 
-  // This is a very sneaky array[0].data It's a holdover from a previous version of this code we could probably actually iterate
-  // over this and get each entry decoded from base64
-  // This was set to any because we just want whatever comes out of here to be base64 decoded and reset to the other value
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  let [JSONPayload]: any[] = potentialPayload.map((payload) => payload.data);
-
-  JSONPayload = window.atob(JSONPayload);
+  let JSONPayload = potentialPayload.map(decodePayload);
 
   if (port) {
     const webSocket = dataConverterWebsocket.websocket;
@@ -52,5 +47,42 @@ export const convertPayloadToJson = async (
     anyAttributes.result.payloads = JSONPayload;
   }
 
+  // Decode Search Attributes
+  if (anyAttributes?.searchAttributes?.indexedFields) {
+    const searchAttributes = anyAttributes?.searchAttributes?.indexedFields;
+
+    Object.entries(searchAttributes).forEach(([key, value]) => {
+      searchAttributes[key] = decodePayload(value);
+    });
+  }
+
+  // Decode Memo
+  if (anyAttributes?.memo?.fields) {
+    const memo = anyAttributes?.memo?.fields;
+
+    Object.entries(memo).forEach(([key, value]) => {
+      memo[key] = decodePayload(value);
+    });
+  }
+
+  // Decode Header
+  if (anyAttributes?.header?.fields) {
+    const header = anyAttributes?.header?.fields;
+
+    Object.entries(header).forEach(([key, value]) => {
+      header[key] = decodePayload(value);
+    });
+  }
+
+  // Decode Query Result
+  // This one is a best guess from the previous codebase and needs verified
+  if (anyAttributes?.queryResult) {
+    const queryResult = anyAttributes?.queryResult;
+
+    Object.entries(queryResult).forEach(([key, value]) => {
+      queryResult[key] = decodePayload(value);
+    });
+  }
+
   return anyAttributes;
 };
diff --git a/src/routes/_header.svelte b/src/routes/_header.svelte
index 6fb9777e..f267fc51 100644
--- a/src/routes/_header.svelte
+++ b/src/routes/_header.svelte
@@ -36,18 +36,25 @@
     </a>
     {#if $settings.auth?.enabled}
       {#if $user?.email}
-        <img
-          src={$user.picture}
-          alt="User Avatar"
-          class="rounded-full h-6 w-6"
-        />
+        <button
+          class="header-button min-w-min"
+          on:click={() =>
+            window.location.assign(import.meta.env.VITE_API + '/auth/logout')}
+        >
+          Logout
+          <img
+            src={$user.picture}
+            alt="User Avatar"
+            class="rounded-full h-6 w-6 ml-2.5"
+          />
+        </button>
       {:else}
         <button
           class="header-button"
           on:click={() =>
             window.location.assign(import.meta.env.VITE_API + '/auth/sso')}
         >
-          Sign In
+          Login
         </button>
       {/if}
     {/if}
@@ -56,7 +63,7 @@
 
 <style lang="postcss">
   .header-button {
-    @apply block px-4 py-2 text-sm text-white;
+    @apply block px-4 py-2 text-sm text-white flex items-center justify-center;
   }
 
   .header-button:hover {
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
index ccd7930d..82201dfe 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/history/__layout.svelte
@@ -21,16 +21,15 @@
   import { setContext } from 'svelte';
   import {
     faCode,
-    faDownload,
     faLayerGroup,
     faStream,
   } from '@fortawesome/free-solid-svg-icons';
 
   import { routeFor } from '$lib/utilities/route-for';
-  import { createDataUrl } from '$lib/utilities/create-data-url';
   import { fetchEvents } from '$lib/services/events-service';
   import { refreshable } from '$lib/stores/refreshable';
 
+  import ExportHistory from '$lib/components/export-history.svelte';
   import ToggleButton from '$lib/components/toggle-button.svelte';
   import ToggleButtons from '$lib/components/toggle-buttons.svelte';
 
@@ -59,7 +58,11 @@
           href={routeFor('workflow.events.json', workflow)}
         />
       </ToggleButtons>
-      <ToggleButton icon={faDownload} href={createDataUrl(events)} />
+      <ExportHistory
+        namespace={parameters.namespace}
+        workflowId={parameters.executionId}
+        runId={parameters.runId}
+      />
     </div>
   </nav>
   <slot />


Test patch:
diff --git a/src/lib/utilities/persist-store.ts b/src/lib/stores/persist-store.ts
similarity index 100%
rename from src/lib/utilities/persist-store.ts
rename to src/lib/stores/persist-store.ts
diff --git a/src/lib/utilities/create-data-url.test.ts b/src/lib/utilities/create-data-url.test.ts
deleted file mode 100644
index 52b17437..00000000
--- a/src/lib/utilities/create-data-url.test.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { createDataUrl } from './create-data-url';
-
-describe(createDataUrl, () => {
-  it('should return a data-encoded URI based on an object', () => {
-    const example = { hello: 123 };
-    expect(createDataUrl(example)).toBe(
-      'data:application/json;charset=utf-8,%7B%22hello%22%3A123%7D',
-    );
-  });
-});
diff --git a/src/lib/utilities/decode-payload.test.ts b/src/lib/utilities/decode-payload.test.ts
new file mode 100644
index 00000000..df814c37
--- /dev/null
+++ b/src/lib/utilities/decode-payload.test.ts
@@ -0,0 +1,59 @@
+/**
+ * @jest-environment jsdom
+ */
+
+// Use jsdom jest environment for access to window.atob
+
+import { decodePayload } from './decode-payload-lazy';
+const WebDecodePayload = {
+  metadata: {
+    encoding: 'YmluYXJ5L2VuY3J5cHRlZA==',
+    'encryption-key-id': '',
+  },
+  data: 'dlSjfJltMHoITwMRv4gqQZsf3yXLo5UVtroA7ZXM3Eeggnrkzc+h6+xhQm2TQ6z8rska0QtWu7Ye3AhYfGw+8mY6/5NN+La4TJmKOe5/EKfL2znMbIXBzXLaeK4MjjIrxo2gI1weYHBb',
+};
+
+const JsonPlainEncoded = {
+  metadata: {
+    encoding: 'anNvbi9wbGFpbg==',
+    type: 'S2V5d29yZA==',
+  },
+  data: 'InRlc3RAdGVzdC5jb20i',
+};
+
+const ProtobufEncoded = {
+  metadata: {
+    encoding: 'anNvbi9wcm90b2J1Zg==',
+    type: 'S2V5d29yZA==',
+  },
+  data: 'InRlc3RAdGVzdC5jb20i',
+};
+
+const BinaryNullEncodedNoData = {
+  metadata: {
+    encoding: 'YmluYXJ5L251bGw=',
+  },
+  data: null,
+};
+
+const Base64Decoded = 'test@test.com';
+
+describe(decodePayload, () => {
+  it('Should not decode a payload with encoding binary/encrypted', () => {
+    expect(decodePayload(WebDecodePayload)).toEqual(WebDecodePayload);
+  });
+
+  it('Should not decode a payload with encoding binary/encrypted', () => {
+    expect(decodePayload(BinaryNullEncodedNoData)).toEqual(
+      BinaryNullEncodedNoData,
+    );
+  });
+
+  it('Should decode a payload with encoding json/plain', () => {
+    expect(decodePayload(JsonPlainEncoded)).toEqual(Base64Decoded);
+  });
+
+  it('Should decode a payload with encoding json/protobuf', () => {
+    expect(decodePayload(ProtobufEncoded)).toEqual(Base64Decoded);
+  });
+});