Repository: temporalio/ui
Pull request #86 (2021-09-22 16:58:36)

Activities View
Implements a first pass at a compact activities view.


Patch:
diff --git a/package-lock.json b/package-lock.json
index 2cbdee84..7256edd3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
       "name": "temporal-ui",
       "version": "0.0.1",
       "dependencies": {
+        "@types/lodash": "^4.14.173",
         "base-64": "^1.0.0",
         "date-fns": "^2.23.0",
         "json-beautify": "^1.1.1",
+        "lodash": "^4.17.21",
         "svelte-hero-icons": "^2.5.0",
         "url-pattern": "^1.0.3"
       },
@@ -4814,6 +4816,11 @@
       "integrity": "sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==",
       "dev": true
     },
+    "node_modules/@types/lodash": {
+      "version": "4.14.173",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.173.tgz",
+      "integrity": "sha512-vv0CAYoaEjCw/mLy96GBTnRoZrSxkGE0BKzKimdR8P3OzrNYNvBgtW7p055A+E8C31vXNUhWKoFCbhq7gbyhFg=="
+    },
     "node_modules/@types/long": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
@@ -14913,8 +14920,7 @@
     "node_modules/lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "dev": true
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
     "node_modules/lodash-es": {
       "version": "4.17.21",
@@ -27551,6 +27557,11 @@
       "integrity": "sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==",
       "dev": true
     },
+    "@types/lodash": {
+      "version": "4.14.173",
+      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.173.tgz",
+      "integrity": "sha512-vv0CAYoaEjCw/mLy96GBTnRoZrSxkGE0BKzKimdR8P3OzrNYNvBgtW7p055A+E8C31vXNUhWKoFCbhq7gbyhFg=="
+    },
     "@types/long": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz",
@@ -35594,8 +35605,7 @@
     "lodash": {
       "version": "4.17.21",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
-      "dev": true
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
     },
     "lodash-es": {
       "version": "4.17.21",
diff --git a/package.json b/package.json
index 98ed60a4..2a7c86cb 100644
--- a/package.json
+++ b/package.json
@@ -25,9 +25,11 @@
     "build-stories": "build-storybook"
   },
   "dependencies": {
+    "@types/lodash": "^4.14.173",
     "base-64": "^1.0.0",
     "date-fns": "^2.23.0",
     "json-beautify": "^1.1.1",
+    "lodash": "^4.17.21",
     "svelte-hero-icons": "^2.5.0",
     "url-pattern": "^1.0.3"
   },
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/events.d.ts b/src/events.d.ts
similarity index 100%
rename from src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/events.d.ts
rename to src/events.d.ts
diff --git a/src/global.d.ts b/src/global.d.ts
index 2169ff39..d3485ac2 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -27,3 +27,24 @@ type WorkflowExecutionFilters = {
 };
 
 type EventFormat = 'grid' | 'json';
+
+type ActivityStatus =
+  | 'Started'
+  | 'Scheduled'
+  | 'Completed'
+  | 'Failed'
+  | 'TimedOut'
+  | 'CancelRequested'
+  | 'Canceled';
+
+type Activity = {
+  id?: string;
+  status?: ActivityStatus;
+  activityTaskStartedEvent?: ActivityTaskStartedEvent;
+  activityTaskScheduledEvent?: ActivityTaskScheduledEvent;
+  activityTaskCompletedEvent?: ActivityTaskCompletedEvent;
+  activityTaskFailedEvent?: ActivityTaskFailedEvent;
+  activityTaskTimedOutEvent?: ActivityTaskTimedOutEvent;
+  activityTaskCancelRequestedEvent?: ActivityTaskCancelRequestedEvent;
+  activityTaskCanceledEvent?: ActivityTaskCanceledEvent;
+};
diff --git a/src/lib/components/filter-select.svelte b/src/lib/components/filter-select.svelte
index 7a9a477b..b97b957a 100644
--- a/src/lib/components/filter-select.svelte
+++ b/src/lib/components/filter-select.svelte
@@ -6,7 +6,7 @@
 
 <div>
   <label for={id} class="text-gray-600 text-xs block">{name}</label>
-  <select class="block border-2 text-base p-2" {id} bind:value>
+  <select class="block border-2 text-base p-2" {id} bind:value on:change>
     <slot />
   </select>
 </div>
diff --git a/src/lib/components/time-range-select.svelte b/src/lib/components/time-range-select.svelte
new file mode 100644
index 00000000..30ce6677
--- /dev/null
+++ b/src/lib/components/time-range-select.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+  import type { Writable } from 'svelte/store';
+  import { toDuration, toString } from '$lib/utilities/to-duration';
+
+  import Select from '$lib/components/filter-select.svelte';
+
+  const updateRange = (event: Event) => {
+    const target = event.target as HTMLSelectElement;
+    const duration = toDuration(target.value);
+    $range = duration;
+  };
+
+  export let key: string;
+  export let range: Writable<Duration>;
+
+  const durations: { [key: string]: Duration } = {
+    '10 minutes': { minutes: 10 },
+    '60 minutes': { minutes: 60 },
+    '3 hours': { hours: 3 },
+    '24 hours': { hours: 24 },
+    '3 days': { days: 3 },
+    '7 days': { days: 7 },
+    '30 days': { days: 30 },
+    '90 days': { days: 3 },
+  };
+</script>
+
+<Select
+  id={`${key}-time-range`}
+  name="Time Range"
+  value={toString($range)}
+  on:change={updateRange}
+>
+  <option value={null} />
+  {#each Object.keys(durations) as duration}
+    <option value={duration}>Last {duration}</option>
+  {/each}
+</Select>
diff --git a/src/lib/models/workflow-execution.ts b/src/lib/models/workflow-execution.ts
index f46a421f..2e79cb0b 100644
--- a/src/lib/models/workflow-execution.ts
+++ b/src/lib/models/workflow-execution.ts
@@ -19,7 +19,7 @@ export const toWorkflowExecution = (
 export const toWorkflowExecutions = (
   executions: WorkflowExecutionInfo[],
 ): WorkflowExecution[] => {
-  return executions.map(
+  return (executions || []).map(
     (workflowExecutionInfo) => new WorkflowExecution({ workflowExecutionInfo }),
   );
 };
diff --git a/src/lib/services/workflow-execution-service.ts b/src/lib/services/workflow-execution-service.ts
index 59a63a47..7007e7e1 100644
--- a/src/lib/services/workflow-execution-service.ts
+++ b/src/lib/services/workflow-execution-service.ts
@@ -1,3 +1,5 @@
+import { sub, formatISO } from 'date-fns';
+
 import { paginated } from '$lib/utilities/paginated';
 import { toURL } from '$lib/utilities/to-url';
 import type {
@@ -25,6 +27,7 @@ export type GetPollersResponse = {
 
 type FetchWorkflows<T> = NamespaceScopedRequest & {
   onUpdate?: (results: Omit<T, 'nextPageToken'>) => void;
+  startTime?: Duration;
   request?: typeof fetch;
 };
 
@@ -38,11 +41,15 @@ const fetchWorkflows =
   async ({
     namespace,
     onUpdate = (x) => x,
+    startTime = { days: 1 },
     request = fetch,
   }: FetchWorkflows<ListWorkflowExecutionsResponse>) => {
     const { executions } = await paginated(async (token: string) => {
       const url = toURL(`${base}/namespaces/${namespace}/workflows/${type}`, {
         next_page_token: token,
+        'start_time_filter.earliest_time': formatISO(
+          sub(new Date(), startTime),
+        ),
       });
 
       const response = await request(url);
diff --git a/src/lib/stores/activities.ts b/src/lib/stores/activities.ts
new file mode 100644
index 00000000..4f673f55
--- /dev/null
+++ b/src/lib/stores/activities.ts
@@ -0,0 +1,120 @@
+import { derived, get, Readable } from 'svelte/store';
+import {
+  isActivityTaskScheduledEvent,
+  isActivityTaskStartedEvent,
+  isActivityTaskCompletedEvent,
+  isActivityTaskFailedEvent,
+  isActivityTaskTimedOutEvent,
+  isActivityTaskCancelRequestedEvent,
+  isActivityTaskCanceledEvent,
+} from '$lib/utilities/is-event-type';
+
+type ActivitiesMap = { [key: string]: Activity };
+type ActivityStore = Readable<Activity[]> & {
+  getActivity: (id: string | number) => Readable<Activity>;
+};
+
+const setActivityId = (activities: ActivitiesMap) => (id: string) => {
+  if (!activities[id]) activities[id] = {};
+  activities[id].id = id;
+};
+
+const setActivityStatus =
+  (activities: ActivitiesMap) => (id: string, status: ActivityStatus) => {
+    if (!activities[id]) activities[id] = {};
+    activities[id].status = status;
+  };
+
+const addToActivities =
+  (activities: ActivitiesMap) =>
+  (
+    id: string,
+    property: keyof Omit<Activity, 'id' | 'status'>,
+    event: HistoryEvent,
+  ) => {
+    if (!activities[id]) activities[id] = {};
+    activities[id][property] = event;
+  };
+
+export const createActivityStore = <
+  E extends HistoryEvent[],
+  S extends Readable<E>,
+>(
+  eventStore: S,
+): ActivityStore => {
+  const store = derived(
+    eventStore,
+    (events) => {
+      const activities: ActivitiesMap = {};
+      const add = addToActivities(activities);
+      const setId = setActivityId(activities);
+      const setStatus = setActivityStatus(activities);
+
+      for (const event of events) {
+        if (isActivityTaskScheduledEvent(event)) {
+          const id = event.activityTaskScheduledEventAttributes.activityId;
+          add(id, 'activityTaskScheduledEvent', event);
+          setId(id);
+          setStatus(id, 'Started');
+        }
+
+        if (isActivityTaskStartedEvent(event)) {
+          const id = String(
+            event.activityTaskStartedEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskStartedEvent', event);
+          setStatus(id, 'Started');
+        }
+
+        if (isActivityTaskCompletedEvent(event)) {
+          const id = String(
+            event.activityTaskCompletedEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskCompletedEvent', event);
+          setStatus(id, 'Completed');
+        }
+
+        if (isActivityTaskFailedEvent(event)) {
+          const id = String(
+            event.activityTaskFailedEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskFailedEvent', event);
+          setStatus(id, 'Failed');
+        }
+
+        if (isActivityTaskTimedOutEvent(event)) {
+          const id = String(
+            event.activityTaskTimedOutEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskTimedOutEvent', event);
+          setStatus(id, 'TimedOut');
+        }
+
+        if (isActivityTaskCancelRequestedEvent(event)) {
+          const id = String(
+            event.activityTaskCancelRequestedEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskCancelRequestedEvent', event);
+          setStatus(id, 'CancelRequested');
+        }
+
+        if (isActivityTaskCanceledEvent(event)) {
+          const id = String(
+            event.activityTaskCanceledEventAttributes.scheduledEventId,
+          );
+          add(id, 'activityTaskCanceledEvent', event);
+          setStatus(id, 'Canceled');
+        }
+      }
+      return activities;
+    },
+    {},
+  );
+
+  const activities = derived(store, ($store) => Object.values($store));
+
+  return {
+    ...activities,
+    getActivity: (id) => derived(store, ($store) => get(store)[id] || {}),
+  };
+};
diff --git a/src/lib/stores/data.ts b/src/lib/stores/data.ts
deleted file mode 100644
index 2f626c4e..00000000
--- a/src/lib/stores/data.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { derived, get, Readable, writable } from 'svelte/store';
-
-export type Slice<T> = {
-  isLoading: boolean;
-  isError: boolean;
-  data: T | null;
-};
-
-export type SliceStore<T> = Readable<Slice<T>>;
-
-export type SliceRequestConfiguration<T, U> = {
-  initialData?: U;
-  format?: (response: T) => U;
-};
-
-const store = writable({});
-
-const update =
-  <T>(key: string) =>
-  (data: Slice<T>) =>
-    store.update((s) => ({ ...s, [key]: { ...data } }));
-
-const newSlice = <T>(initialState?: T): Slice<T> => ({
-  isLoading: true,
-  isError: false,
-  data: initialState || null,
-});
-
-function request<T, U>(
-  key: string,
-  request: () => Promise<T>,
-  configuration: SliceRequestConfiguration<T, U> = {},
-): SliceStore<U> {
-  const { initialData, format = (i) => i } = configuration;
-  const currentState: Slice<U> = get(store)[key] || newSlice(initialData);
-  const sliceStore = derived(store, (s) => s[key]);
-  const updateKey = update(key);
-
-  updateKey({ ...currentState, isLoading: true });
-
-  request().then((data) => {
-    updateKey({
-      isLoading: false,
-      isError: false,
-      data: format(data),
-    });
-  });
-
-  return {
-    subscribe: sliceStore.subscribe,
-  };
-}
-
-export const query = {
-  subscribe: store.subscribe,
-  request,
-};
diff --git a/src/lib/stores/events.ts b/src/lib/stores/events.ts
index 082b7d52..34d2ec59 100644
--- a/src/lib/stores/events.ts
+++ b/src/lib/stores/events.ts
@@ -1,12 +1,13 @@
 import { derived, Writable, writable } from 'svelte/store';
-import type { GetWorkflowExecutionHistoryResponse } from '$types';
+import type { GetWorkflowExecutionHistoryResponse, HistoryEvent } from '$types';
 import { fetchEvents } from '$lib/services/workflow-execution-service';
+import { createActivityStore } from './activities';
 
 type EventHistoryStore = {
   loading: boolean;
   updating: boolean;
   ids: { [key: string]: boolean };
-  events: { [key: string]: BaseEvent };
+  events: { [key: string]: HistoryEvent };
 };
 
 const stores: { [key: string]: ReturnType<typeof createStore> } = {};
@@ -65,21 +66,28 @@ export const createStore = (
     },
   );
 
-  const all = derived(store, ($store) => Object.values($store.events));
+  const all = derived(store, ($store) => Object.values($store.events), []);
   const format = writable<EventFormat>('grid');
   const type = writable<string>(null);
 
-  const filtered = derived([all, type], ([$all, eventType]) => {
-    return $all.filter((event) => {
-      if (eventType && !String(event.eventType).startsWith(eventType))
-        return false;
-      return true;
-    });
-  });
+  const filtered = derived(
+    [all, type],
+    ([$all, eventType]) => {
+      return $all.filter((event) => {
+        if (eventType && !String(event.eventType).startsWith(eventType))
+          return false;
+        return true;
+      });
+    },
+    [],
+  );
+
+  const activities = createActivityStore(all);
 
   return {
     all,
     filtered,
+    activities,
     format,
     type,
   };
diff --git a/src/lib/stores/full-screen.ts b/src/lib/stores/full-screen.ts
index cecc69f5..a22328d9 100644
--- a/src/lib/stores/full-screen.ts
+++ b/src/lib/stores/full-screen.ts
@@ -3,7 +3,7 @@ import { derived } from 'svelte/store';
 import UrlPattern from 'url-pattern';
 
 const pattern = new UrlPattern(
-  '/namespaces/:namespace/workflows/:executionId/:runId/:view',
+  '/namespaces/:namespace/workflows/:executionId/:runId/:view(/*)',
 );
 
 export const isFullScreen = derived(page, ($page) => {
diff --git a/src/lib/stores/workflows.ts b/src/lib/stores/workflows.ts
index 17bdf626..87b64c17 100644
--- a/src/lib/stores/workflows.ts
+++ b/src/lib/stores/workflows.ts
@@ -1,4 +1,4 @@
-import { derived, writable, Writable } from 'svelte/store';
+import { derived, writable, Writable, get } from 'svelte/store';
 import type { ListWorkflowExecutionsResponse } from '$types';
 import { unique } from '$lib/utilities/unique';
 
@@ -8,6 +8,7 @@ import {
 } from '$lib/models/workflow-execution';
 
 import { fetchAllWorkflows } from '$lib/services/workflow-execution-service';
+import { createStoreWithCallback } from '$lib/utilities/create-store-with-callback';
 
 type WorkflowStore = {
   loading: boolean;
@@ -39,6 +40,16 @@ const updateWorkflows =
   };
 
 export const createStore = (namespace: string) => {
+  const update = () => {
+    const startTime = get(range);
+
+    fetchAllWorkflows({
+      namespace,
+      onUpdate: updateWorkflows(store),
+      startTime,
+    });
+  };
+
   const store = writable<WorkflowStore>(
     {
       loading: true,
@@ -47,21 +58,24 @@ export const createStore = (namespace: string) => {
       workflows: {},
     },
     () => {
-      fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+      let idleCallback: number;
+
+      update();
 
       const interval = setInterval(() => {
-        fetchAllWorkflows({ namespace, onUpdate: updateWorkflows(store) });
+        idleCallback = requestIdleCallback(update);
       }, 30000);
 
       return () => {
+        if (idleCallback) cancelIdleCallback(idleCallback);
         clearInterval(interval);
       };
     },
   );
 
-  const get = (id: string) => derived(store, ($store) => $store.workflows[id]);
   const all = derived(store, ($store) => Object.values($store.workflows));
   const ids = derived(store, ($store) => Object.keys($store.ids));
+  const range = createStoreWithCallback<Duration>({ hours: 24 }, update);
 
   const status = writable<WorkflowStatus>(null);
   const workflowType = writable<WorkflowType>(null);
@@ -91,10 +105,10 @@ export const createStore = (namespace: string) => {
 
   return {
     ids,
-    get,
     all,
     filtered,
     workflowTypes,
+    range,
     filters: {
       status,
       workflowType,
diff --git a/src/lib/utilities/create-store-with-callback.ts b/src/lib/utilities/create-store-with-callback.ts
new file mode 100644
index 00000000..c7a82cfa
--- /dev/null
+++ b/src/lib/utilities/create-store-with-callback.ts
@@ -0,0 +1,24 @@
+import { Updater, Writable, writable } from 'svelte/store';
+
+const call = (fn: () => void) => {
+  if (fn && typeof fn === 'function') fn();
+};
+
+export const createStoreWithCallback = <T>(
+  value: T,
+  callback: () => void,
+): Writable<T> => {
+  const store = writable(value);
+
+  return {
+    ...store,
+    set: (value: T) => {
+      store.set(value);
+      call(callback);
+    },
+    update: (updater: Updater<T>) => {
+      store.update(updater);
+      call(callback);
+    },
+  };
+};
diff --git a/src/lib/utilities/get-activity-url.ts b/src/lib/utilities/get-activity-url.ts
new file mode 100644
index 00000000..b60b39c3
--- /dev/null
+++ b/src/lib/utilities/get-activity-url.ts
@@ -0,0 +1,8 @@
+export const getActivityUrl = (
+  namespace: string,
+  executionId: string,
+  runId: string,
+  activityId = '',
+) => {
+  return `/namespaces/${namespace}/workflows/${executionId}/${runId}/activities/${activityId}`;
+};
diff --git a/src/lib/utilities/is-event-type.ts b/src/lib/utilities/is-event-type.ts
new file mode 100644
index 00000000..e9f825d2
--- /dev/null
+++ b/src/lib/utilities/is-event-type.ts
@@ -0,0 +1,239 @@
+export const isWorkflowExecutionStartedEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionStartedEvent => {
+  return !!event.workflowExecutionStartedEventAttributes;
+};
+
+export const isWorkflowExecutionCompletedEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionCompletedEvent => {
+  return !!event.workflowExecutionCompletedEventAttributes;
+};
+
+export const isWorkflowExecutionFailedEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionFailedEvent => {
+  return !!event.workflowExecutionFailedEventAttributes;
+};
+
+export const isWorkflowExecutionTimedOutEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionTimedOutEvent => {
+  return !!event.workflowExecutionTimedOutEventAttributes;
+};
+
+export const isWorkflowTaskScheduledEvent = (
+  event: HistoryEvent,
+): event is WorkflowTaskScheduledEvent => {
+  return !!event.workflowTaskScheduledEventAttributes;
+};
+
+export const isWorkflowTaskStartedEvent = (
+  event: HistoryEvent,
+): event is WorkflowTaskStartedEvent => {
+  return !!event.workflowTaskStartedEventAttributes;
+};
+
+export const isWorkflowTaskCompletedEvent = (
+  event: HistoryEvent,
+): event is WorkflowTaskCompletedEvent => {
+  return !!event.workflowTaskCompletedEventAttributes;
+};
+
+export const isWorkflowTaskTimedOutEvent = (
+  event: HistoryEvent,
+): event is WorkflowTaskTimedOutEvent => {
+  return !!event.workflowTaskTimedOutEventAttributes;
+};
+
+export const isWorkflowTaskFailedEvent = (
+  event: HistoryEvent,
+): event is WorkflowTaskFailedEvent => {
+  return !!event.workflowTaskFailedEventAttributes;
+};
+
+export const isActivityTaskScheduledEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskScheduledEvent => {
+  return !!event.activityTaskScheduledEventAttributes;
+};
+
+export const isActivityTaskStartedEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskStartedEvent => {
+  return !!event.activityTaskStartedEventAttributes;
+};
+
+export const isActivityTaskCompletedEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskCompletedEvent => {
+  return !!event.activityTaskCompletedEventAttributes;
+};
+
+export const isActivityTaskFailedEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskFailedEvent => {
+  return !!event.activityTaskFailedEventAttributes;
+};
+
+export const isActivityTaskTimedOutEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskTimedOutEvent => {
+  return !!event.activityTaskTimedOutEventAttributes;
+};
+
+export const isTimerStartedEvent = (
+  event: HistoryEvent,
+): event is TimerStartedEvent => {
+  return !!event.timerStartedEventAttributes;
+};
+
+export const isTimerFiredEvent = (
+  event: HistoryEvent,
+): event is TimerFiredEvent => {
+  return !!event.timerFiredEventAttributes;
+};
+
+export const isActivityTaskCancelRequestedEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskCancelRequestedEvent => {
+  return !!event.activityTaskCancelRequestedEventAttributes;
+};
+
+export const isActivityTaskCanceledEvent = (
+  event: HistoryEvent,
+): event is ActivityTaskCanceledEvent => {
+  return !!event.activityTaskCanceledEventAttributes;
+};
+
+export const isTimerCanceledEvent = (
+  event: HistoryEvent,
+): event is TimerCanceledEvent => {
+  return !!event.timerCanceledEventAttributes;
+};
+
+export const isMarkerRecordedEvent = (
+  event: HistoryEvent,
+): event is MarkerRecordedEvent => {
+  return !!event.markerRecordedEventAttributes;
+};
+
+export const isWorkflowExecutionSignaledEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionSignaledEvent => {
+  return !!event.workflowExecutionSignaledEventAttributes;
+};
+
+export const isWorkflowExecutionTerminatedEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionTerminatedEvent => {
+  return !!event.workflowExecutionTerminatedEventAttributes;
+};
+
+export const isWorkflowExecutionCancelRequestedEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionCancelRequestedEvent => {
+  return !!event.workflowExecutionCancelRequestedEventAttributes;
+};
+
+export const isWorkflowExecutionCanceledEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionCanceledEvent => {
+  return !!event.workflowExecutionCanceledEventAttributes;
+};
+
+export const isRequestCancelExternalWorkflowExecutionInitiatedEvent = (
+  event: HistoryEvent,
+): event is RequestCancelExternalWorkflowExecutionInitiatedEvent => {
+  return !!event.requestCancelExternalWorkflowExecutionInitiatedEventAttributes;
+};
+
+export const isRequestCancelExternalWorkflowExecutionFailedEvent = (
+  event: HistoryEvent,
+): event is RequestCancelExternalWorkflowExecutionFailedEvent => {
+  return !!event.requestCancelExternalWorkflowExecutionFailedEventAttributes;
+};
+
+export const isExternalWorkflowExecutionCancelRequestedEvent = (
+  event: HistoryEvent,
+): event is ExternalWorkflowExecutionCancelRequestedEvent => {
+  return !!event.externalWorkflowExecutionCancelRequestedEventAttributes;
+};
+
+export const isWorkflowExecutionContinuedAsNewEvent = (
+  event: HistoryEvent,
+): event is WorkflowExecutionContinuedAsNewEvent => {
+  return !!event.workflowExecutionContinuedAsNewEventAttributes;
+};
+
+export const isStartChildWorkflowExecutionInitiatedEvent = (
+  event: HistoryEvent,
+): event is StartChildWorkflowExecutionInitiatedEvent => {
+  return !!event.startChildWorkflowExecutionInitiatedEventAttributes;
+};
+
+export const isStartChildWorkflowExecutionFailedEvent = (
+  event: HistoryEvent,
+): event is StartChildWorkflowExecutionFailedEvent => {
+  return !!event.startChildWorkflowExecutionFailedEventAttributes;
+};
+
+export const isChildWorkflowExecutionStartedEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionStartedEvent => {
+  return !!event.childWorkflowExecutionStartedEventAttributes;
+};
+
+export const isChildWorkflowExecutionCompletedEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionCompletedEvent => {
+  return !!event.childWorkflowExecutionCompletedEventAttributes;
+};
+
+export const isChildWorkflowExecutionFailedEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionFailedEvent => {
+  return !!event.childWorkflowExecutionFailedEventAttributes;
+};
+
+export const isChildWorkflowExecutionCanceledEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionCanceledEvent => {
+  return !!event.childWorkflowExecutionCanceledEventAttributes;
+};
+
+export const isChildWorkflowExecutionTimedOutEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionTimedOutEvent => {
+  return !!event.childWorkflowExecutionTimedOutEventAttributes;
+};
+
+export const isChildWorkflowExecutionTerminatedEvent = (
+  event: HistoryEvent,
+): event is ChildWorkflowExecutionTerminatedEvent => {
+  return !!event.childWorkflowExecutionTerminatedEventAttributes;
+};
+
+export const isSignalExternalWorkflowExecutionInitiatedEvent = (
+  event: HistoryEvent,
+): event is SignalExternalWorkflowExecutionInitiatedEvent => {
+  return !!event.signalExternalWorkflowExecutionInitiatedEventAttributes;
+};
+
+export const isSignalExternalWorkflowExecutionFailedEvent = (
+  event: HistoryEvent,
+): event is SignalExternalWorkflowExecutionFailedEvent => {
+  return !!event.signalExternalWorkflowExecutionFailedEventAttributes;
+};
+
+export const isExternalWorkflowExecutionSignaledEvent = (
+  event: HistoryEvent,
+): event is ExternalWorkflowExecutionSignaledEvent => {
+  return !!event.externalWorkflowExecutionSignaledEventAttributes;
+};
+
+export const isUpsertWorkflowSearchAttributesEvent = (
+  event: HistoryEvent,
+): event is UpsertWorkflowSearchAttributesEvent => {
+  return !!event.upsertWorkflowSearchAttributesEventAttributes;
+};
diff --git a/src/lib/utilities/to-duration.ts b/src/lib/utilities/to-duration.ts
new file mode 100644
index 00000000..d540fa96
--- /dev/null
+++ b/src/lib/utilities/to-duration.ts
@@ -0,0 +1,13 @@
+const durationPattern =
+  /(\d+)\s(years|months|weeks|days|hours|minutes|seconds)/;
+
+export const toDuration = (value: string): Duration => {
+  const [, amount, units] = value.match(durationPattern);
+  return { [units]: parseInt(amount, 10) };
+};
+
+export const toString = (duration: Duration) => {
+  const units = Object.keys(duration)[0];
+  const amount: number = duration[units];
+  return `${amount} ${units}`;
+};
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
index 1967bedb..cf1876d5 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/_tabs.svelte
@@ -12,15 +12,19 @@
   $: workflowUrl = getWorkflowExecutionUrl($namespace, workflow);
   $: summaryUrl = workflowUrl + '/summary';
   $: eventsUrl = workflowUrl + '/events';
+  $: activitiesUrl = workflowUrl + '/activities';
 </script>
 
 <nav class="mt-6 border-b-2 px-2 flex">
-  <a class:active={pathMatches(summaryUrl, $page.path)} href={summaryUrl}
-    >Summary</a
-  >
-  <a class:active={pathMatches(eventsUrl, $page.path)} href={eventsUrl}
-    >Events</a
-  >
+  <a class:active={pathMatches(summaryUrl, $page.path)} href={summaryUrl}>
+    Summary
+  </a>
+  <a class:active={pathMatches(eventsUrl, $page.path)} href={eventsUrl}>
+    Events
+  </a>
+  <a class:active={pathMatches(activitiesUrl, $page.path)} href={activitiesUrl}>
+    Activities
+  </a>
 </nav>
 
 <style lang="postcss">
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/[activity].svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/[activity].svelte
new file mode 100644
index 00000000..61e83503
--- /dev/null
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/[activity].svelte
@@ -0,0 +1,50 @@
+<script context="module" lang="ts">
+  import type { LoadInput } from '@sveltejs/kit';
+
+  export async function load({ page }: LoadInput) {
+    const {
+      workflow: executionId,
+      run: runId,
+      namespace,
+      activity,
+    } = page.params;
+
+    return {
+      props: {
+        executionId,
+        runId,
+        namespace,
+        activity,
+      },
+    };
+  }
+</script>
+
+<script lang="ts">
+  import omit from 'lodash/omit';
+  import { createEventStore } from '$lib/stores/events';
+
+  import KeyValueTable from '$lib/components/key-value-table.svelte';
+
+  export let namespace: string;
+  export let executionId: string;
+  export let runId: string;
+  export let activity: string;
+
+  const { activities } = createEventStore(namespace, executionId, runId);
+  const selectedActivity = activities.getActivity(activity);
+
+  $: events = Object.keys(omit($selectedActivity, ['id', 'status']));
+</script>
+
+<div class="p-4">
+  <h1 class="font-bold text-3xl">Activity {$selectedActivity.id}</h1>
+  <p>{$selectedActivity.status}</p>
+
+  {#each events as event}
+    <h2 class="mt-4 mb-2 border-indigo-400 border-b-2 font-semibold">
+      {event}
+    </h2>
+    <KeyValueTable data={$selectedActivity[event]} />
+  {/each}
+</div>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/__layout.svelte
new file mode 100644
index 00000000..ba7bb53a
--- /dev/null
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/__layout.svelte
@@ -0,0 +1,51 @@
+<script context="module" lang="ts">
+  import type { LoadInput } from '@sveltejs/kit';
+
+  export async function load({ page }: LoadInput) {
+    const { workflow: executionId, run: runId, namespace } = page.params;
+
+    return {
+      props: {
+        executionId,
+        runId,
+        namespace,
+      },
+    };
+  }
+</script>
+
+<script lang="ts">
+  import { page } from '$app/stores';
+  import { createEventStore } from '$lib/stores/events';
+  import { pathMatches } from '$lib/utilities/path-matches';
+  import { getActivityUrl } from '$lib/utilities/get-activity-url';
+
+  export let namespace: string;
+  export let executionId: string;
+  export let runId: string;
+
+  const { activities } = createEventStore(namespace, executionId, runId);
+  $: urlFor = getActivityUrl.bind(null, namespace, executionId, runId);
+  $: isActive = (id: string) => pathMatches(urlFor(id), $page.path);
+</script>
+
+<section class="flex min-h-full">
+  <div class="w-1/3 flex-1">
+    {#each $activities as activity}
+      <a class:active={isActive(activity.id)} href={urlFor(activity.id)}>
+        <article class="p-4 m-4 border-2">
+          <h2>Activity {activity.id}: {activity.status}</h2>
+        </article>
+      </a>
+    {/each}
+  </div>
+  <div class="w-2/3">
+    <slot />
+  </div>
+</section>
+
+<style lang="postcss">
+  .active > article {
+    @apply bg-yellow-100 border-yellow-500;
+  }
+</style>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/index.svelte
new file mode 100644
index 00000000..f2d7925c
--- /dev/null
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/activities/index.svelte
@@ -0,0 +1,1 @@
+Select an activity from the left.
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/__layout.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/__layout.svelte
new file mode 100644
index 00000000..efe1109e
--- /dev/null
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/__layout.svelte
@@ -0,0 +1,37 @@
+<script context="module" lang="ts">
+  import type { LoadInput } from '@sveltejs/kit';
+
+  export async function load({ page }: LoadInput) {
+    const { workflow: executionId, run: runId, namespace } = page.params;
+
+    return {
+      props: {
+        executionId,
+        runId,
+        namespace,
+      },
+    };
+  }
+</script>
+
+<script lang="ts">
+  import { createEventStore } from '$lib/stores/events';
+
+  import EventsFilters from './_events-filters.svelte';
+
+  export let namespace: string;
+  export let executionId: string;
+  export let runId: string;
+
+  const { all, type, format } = createEventStore(namespace, executionId, runId);
+</script>
+
+<div class="px-6 py-6">
+  <EventsFilters
+    events={$all}
+    eventType={type}
+    eventFormat={format}
+    execution={executionId}
+  />
+  <slot />
+</div>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte
deleted file mode 100644
index ea8d6a39..00000000
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/_events.svelte
+++ /dev/null
@@ -1,36 +0,0 @@
-<script lang="ts">
-  import Event from './_event.svelte';
-  import CodeBlock from '$lib/components/code-block.svelte';
-
-  export let eventFormat: EventFormat;
-  export let events: BaseEvent[];
-</script>
-
-<section>
-  {#if eventFormat === 'grid'}
-    <table class="border-collapse w-full border-2 table-fixed">
-      <thead>
-        <tr>
-          <th class="w-1/12">ID</th>
-          <th class="w-2/12">Type</th>
-          <th class="w-2/12">Time</th>
-          <th class="w-7/12">Details</th>
-        </tr>
-      </thead>
-      <tbody>
-        {#each events as event, index}
-          <Event {event} {index} />
-        {/each}
-      </tbody>
-    </table>
-  {/if}
-
-  {#if eventFormat === 'json'}
-    {#each events as event}
-      <CodeBlock
-        heading={`Event ID: ${event.eventId}`}
-        content={JSON.stringify(event)}
-      />
-    {/each}
-  {/if}
-</section>
diff --git a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
index 6b7e60fc..45214aec 100644
--- a/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/[workflow]/[run]/events/index.svelte
@@ -15,28 +15,42 @@
 </script>
 
 <script lang="ts">
+  import Event from './_event.svelte';
+  import CodeBlock from '$lib/components/code-block.svelte';
   import { createEventStore } from '$lib/stores/events';
 
-  import Events from './_events.svelte';
-  import EventsFilters from './_events-filters.svelte';
-
   export let namespace: string;
   export let executionId: string;
   export let runId: string;
 
-  const { all, filtered, type, format } = createEventStore(
-    namespace,
-    executionId,
-    runId,
-  );
+  const { filtered, format } = createEventStore(namespace, executionId, runId);
 </script>
 
-<div class="px-6 py-6">
-  <EventsFilters
-    events={$all}
-    eventType={type}
-    eventFormat={format}
-    execution={executionId}
-  />
-  <Events events={$filtered} eventFormat={$format} />
-</div>
+<section>
+  {#if $format === 'grid'}
+    <table class="border-collapse w-full border-2 table-fixed">
+      <thead>
+        <tr>
+          <th class="w-1/12">ID</th>
+          <th class="w-2/12">Type</th>
+          <th class="w-2/12">Time</th>
+          <th class="w-7/12">Details</th>
+        </tr>
+      </thead>
+      <tbody>
+        {#each $filtered as event, index}
+          <Event {event} {index} />
+        {/each}
+      </tbody>
+    </table>
+  {/if}
+
+  {#if $format === 'json'}
+    {#each $filtered as event}
+      <CodeBlock
+        heading={`Event ID: ${event.eventId}`}
+        content={JSON.stringify(event)}
+      />
+    {/each}
+  {/if}
+</section>
diff --git a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
index ffbe2494..43aba9be 100644
--- a/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
+++ b/src/routes/namespaces/[namespace]/workflows/_workflow-filters.svelte
@@ -4,12 +4,14 @@
 
   import Select from '$lib/components/filter-select.svelte';
   import Input from '$lib/components/filter-input.svelte';
+  import TimeRangeSelect from '$lib/components/time-range-select.svelte';
 
   export let timeFormat: string = 'relative';
 
   $: store = createWorkflowStore($namespace);
   $: workflowTypes = store.workflowTypes;
   $: filters = store.filters;
+  $: range = store.range;
 
   $: status = filters.status;
   $: workflowType = filters.workflowType;
@@ -26,6 +28,21 @@
 </script>
 
 <section class="p-4 flex gap-8">
+  <TimeRangeSelect {range} key="workflows" />
+  <Select
+    id="filter-by-workflow-status"
+    name="Workflow Status"
+    bind:value={$status}
+  >
+    <option value={null} />
+    <option value="Running">Running</option>
+    <option value="TimedOut">Timed Out</option>
+    <option value="Completed">Completed</option>
+    <option value="Failed">Failed</option>
+    <option value="ContinuedAsNew">Continued as New</option>
+    <option value="Canceled">Canceled</option>
+    <option value="Terminated">Terminated</option>
+  </Select>
   <Select
     id="filter-by-workflow-status"
     name="Workflow Status"


Test patch:
diff --git a/src/lib/utilities/create-store-with-callback.test.ts b/src/lib/utilities/create-store-with-callback.test.ts
new file mode 100644
index 00000000..99efd323
--- /dev/null
+++ b/src/lib/utilities/create-store-with-callback.test.ts
@@ -0,0 +1,17 @@
+import { createStoreWithCallback } from './create-store-with-callback';
+
+describe(createStoreWithCallback, () => {
+  it('should call the callback when a new value is set', () => {
+    const fn = jest.fn();
+    const store = createStoreWithCallback(0, fn);
+    store.set(1);
+    expect(fn).toBeCalled();
+  });
+
+  it('should call the callback when value is updated', () => {
+    const fn = jest.fn();
+    const store = createStoreWithCallback(0, fn);
+    store.update((count) => count + 1);
+    expect(fn).toBeCalled();
+  });
+});
diff --git a/src/lib/utilities/to-duration.test.ts b/src/lib/utilities/to-duration.test.ts
new file mode 100644
index 00000000..d4fe39f4
--- /dev/null
+++ b/src/lib/utilities/to-duration.test.ts
@@ -0,0 +1,35 @@
+import { toDuration } from './to-duration';
+
+describe(toDuration, () => {
+  it('should correctly parse "Last 10 minutes"', () => {
+    expect(toDuration('Last 10 minutes')).toEqual({ minutes: 10 });
+  });
+
+  it('should correctly parse "Last 60 minutes"', () => {
+    expect(toDuration('Last 60 minutes')).toEqual({ minutes: 60 });
+  });
+
+  it('should correctly parse "Last 3 hours"', () => {
+    expect(toDuration('Last 3 hours')).toEqual({ hours: 3 });
+  });
+
+  it('should correctly parse "Last 24 hours"', () => {
+    expect(toDuration('Last 24 hours')).toEqual({ hours: 24 });
+  });
+
+  it('should correctly parse "Last 3 days"', () => {
+    expect(toDuration('Last 3 days')).toEqual({ days: 3 });
+  });
+
+  it('should correctly parse "Last 7 days"', () => {
+    expect(toDuration('Last 7 days')).toEqual({ days: 7 });
+  });
+
+  it('should correctly parse "Last 30 days"', () => {
+    expect(toDuration('Last 30 days')).toEqual({ days: 30 });
+  });
+
+  it('should correctly parse "Last 3 months"', () => {
+    expect(toDuration('Last 3 months')).toEqual({ months: 3 });
+  });
+});